diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 76199ca6..d1aa97a5 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -13,7 +13,7 @@ body: attributes: label: Package version description: Which version of `@brazilian-utils/brazilian-utils` are you using? - placeholder: "2.3.0" + placeholder: "2.4.0" validations: required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 56b68a5a..ca59c274 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -9,7 +9,8 @@ - [ ] I updated the documentation if this adds/changes a utility, in **both**: - [ ] `docs/utilities.md` (English) - [ ] `docs/pt-br/utilities.md` (Portuguese) -- [ ] `npm check` passes locally (format, lint, types). +- [ ] `npm run check` passes locally (format, lint, types). +- [ ] I ran `npm run build:llms` if I touched `docs/utilities.md` (the Check workflow fails when `docs/llms.txt` is stale). - [ ] This change does not introduce a breaking change, **or** I flagged it clearly below and it was discussed with maintainers beforehand. - [ ] This change does not add any runtime dependency (this library is zero-runtime-dependency by design). diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 00000000..d7b9d94e --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,7 @@ +# actionlint 1.7.12 does not know GitHub's self-repository `uses: $/...` syntax yet +# (https://github.com/rhysd/actionlint/issues/711); zizmor's self-repository audit and the GitHub +# docs recommend it over the workspace-relative `./...` form. Drop this once actionlint supports it. +paths: + .github/workflows/**/*.yml: + ignore: + - 'specifying action "\$/\.github/actions/setup" in invalid format because ref is missing' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cbcbedc6..4306ce53 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,7 +26,7 @@ jobs: persist-credentials: false - name: Setup - uses: ./.github/actions/setup + uses: $/.github/actions/setup - name: Run build run: vp run build @@ -52,7 +52,7 @@ jobs: persist-credentials: false - name: Setup - uses: ./.github/actions/setup + uses: $/.github/actions/setup - name: Checkout base if: ${{ github.event_name == 'pull_request' }} @@ -69,15 +69,13 @@ jobs: if: ${{ github.event_name == 'pull_request' }} id: base run: | - if [ -f base/scripts/tree-shaking.ts ]; then - node scripts/tree-shaking.ts --json head.json - (cd base && npm ci && npm run build && node scripts/tree-shaking.ts --json ../base.json --surviving ../head.json) || true - fi - if [ -f base.json ]; then - echo "measured=true" >> "$GITHUB_OUTPUT" - else + if [ ! -f base/scripts/tree-shaking.ts ]; then echo "measured=false" >> "$GITHUB_OUTPUT" + exit 0 fi + node scripts/tree-shaking.ts --json head.json + (cd base && npm ci && npm run build && node scripts/tree-shaking.ts --json ../base.json --surviving ../head.json) + echo "measured=true" >> "$GITHUB_OUTPUT" - name: Compare against base if: ${{ github.event_name == 'pull_request' }} @@ -85,10 +83,13 @@ jobs: continue-on-error: true run: | if [ "${{ steps.base.outputs.measured }}" = "true" ]; then + code=2 + echo "code=$code" >> "$GITHUB_OUTPUT" set +e node scripts/tree-shaking.ts --compare base.json --markdown tree-shaking.md code=$? set -e + case "$code" in 0 | 1) ;; *) code=2 ;; esac echo "code=$code" >> "$GITHUB_OUTPUT" exit "$code" else diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 80466e04..18b1480a 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -27,7 +27,7 @@ jobs: persist-credentials: false - name: Setup - uses: ./.github/actions/setup + uses: $/.github/actions/setup - name: Run checks run: vp check diff --git a/.github/workflows/datasets.yml b/.github/workflows/datasets.yml index 16197652..d261f644 100644 --- a/.github/workflows/datasets.yml +++ b/.github/workflows/datasets.yml @@ -23,7 +23,7 @@ jobs: persist-credentials: false - name: Setup - uses: ./.github/actions/setup + uses: $/.github/actions/setup - name: Rebuild datasets run: npm run build:data diff --git a/.github/workflows/live-tests.yml b/.github/workflows/live-tests.yml index 83458c49..d5041bb1 100644 --- a/.github/workflows/live-tests.yml +++ b/.github/workflows/live-tests.yml @@ -22,7 +22,7 @@ jobs: persist-credentials: false - name: Setup - uses: ./.github/actions/setup + uses: $/.github/actions/setup - name: Run live CEP tests run: vp run test:live diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index 1d2e29c9..0058ea56 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -26,7 +26,7 @@ jobs: persist-credentials: false - name: Setup - uses: ./.github/actions/setup + uses: $/.github/actions/setup - name: Mutation test every file run: npm run test:mutation diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a816df8b..058f5416 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -83,7 +83,7 @@ jobs: persist-credentials: false - name: Setup - uses: ./.github/actions/setup + uses: $/.github/actions/setup with: node-version: 24 @@ -104,6 +104,7 @@ jobs: cat tree-shaking.md >> "$GITHUB_STEP_SUMMARY" - name: Ensure npm supports staged publishing and OIDC (npm >= 11.15) + # zizmor: ignore[adhoc-packages] npm is pinned to an exact version and is not a package.json dependency run: npm install -g npm@12.0.2 - name: Stage on npm @@ -126,7 +127,7 @@ jobs: persist-credentials: false - name: Setup - uses: ./.github/actions/setup + uses: $/.github/actions/setup - name: Generate the CycloneDX SBOM of the published package # The package has no runtime dependencies, so the SBOM describes the package itself; diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 67a5b57b..b16427c5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -29,7 +29,7 @@ jobs: persist-credentials: false - name: Setup - uses: ./.github/actions/setup + uses: $/.github/actions/setup with: node-version: ${{ matrix.node-version }} @@ -65,7 +65,7 @@ jobs: persist-credentials: false - name: Setup - uses: ./.github/actions/setup + uses: $/.github/actions/setup - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 @@ -85,7 +85,7 @@ jobs: persist-credentials: false - name: Setup - uses: ./.github/actions/setup + uses: $/.github/actions/setup - name: Setup Deno uses: denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed # v2.0.5 @@ -110,7 +110,7 @@ jobs: persist-credentials: false - name: Setup - uses: ./.github/actions/setup + uses: $/.github/actions/setup - name: Run tests in ${{ matrix.browser }} run: vp test --browser.enabled --browser.name=${{ matrix.browser }} @@ -127,7 +127,7 @@ jobs: persist-credentials: false - name: Setup - uses: ./.github/actions/setup + uses: $/.github/actions/setup - name: Run tests in Safari run: vp test --browser.enabled --browser.name=safari --browser.headless=false diff --git a/.gitignore b/.gitignore index 895e7d16..7465c7a3 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,10 @@ node_modules dist coverage .stryker-tmp -reports +# Every report is generated (stryker, coverage, jscpd) except the API Extractor baseline, which is +# committed so that `npm run check:api` compares the public API against the reviewed one instead of +# writing a new file on every run. +reports/* +!reports/api/ +reports/api/* +!reports/api/brazilian-utils.api.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dffb64b..74ae8e84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [2.3.0](https://github.com/brazilian-utils/javascript/compare/2.2.0...2.3.0) (2026-04-09) +## [2.3.0](https://github.com/brazilian-utils/javascript/compare/2.2.0...2.3.0) (2026-04-08) ### Features @@ -193,5 +193,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 🎡 adjust travis config ([0ec107f](https://github.com/brazilian-utils/javascript/commit/0ec107f1ebee536e15a6bb991750342457bf1a44)) - 🎡 rename travis file ([25da01e](https://github.com/brazilian-utils/javascript/commit/25da01e5c700c17c3e88217c4bf272f5eef5639a)) - -[Unreleased]: https://github.com/brazilian-utils/javascript/compare/2.3.0...HEAD diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f200213e..cc31214e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,24 +28,29 @@ and is invoked through the `npm` scripts below, so you don't need to install any ### Useful scripts -| Command | What it does | -| ------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `npm run check` | Runs `vp check`: format check, lint and type-check together. Run this before opening a PR. | -| `npm run check:fix` | Same as above, but auto-fixes what it can. | -| `npm run format` / `npm run format:check` | Formats the codebase / checks formatting with `vp fmt`. | -| `npm run lint` / `npm run lint:fix` | Lints the codebase with `vp lint`. | -| `npm run test` | Runs the unit test suite with `vp test`. | -| `npm run test:coverage` | Runs tests with coverage (`vp test run --coverage`). | -| `npm run test:bun` | Runs the test suite on [Bun](https://bun.sh) (`bun test src`). | -| `npm run test:deno` | Runs the test suite on [Deno](https://deno.com) (`deno test`). | -| `npm run test:chrome-browser`, `npm run test:firefox-browser`, `npm run test:edge-browser`, `npm run test:safari-browser` | Runs the test suite in real browsers via `vp test --browser.enabled`. | -| `npm run build` | Builds the library with `vp build`. | -| `npm run check:duplication` | Runs [jscpd](https://jscpd.dev) over `src` and `scripts`; any copy-pasted block of 5+ lines / 50+ tokens fails. | -| `npm run check:unused` | Runs [knip](https://knip.dev): unused files, exports, types and dependencies fail. | -| `npm run test:mutation` | Runs [Stryker](https://stryker-mutator.io) mutation tests (`stryker run`); pass `-- --mutate src//.ts` for one file. | -| `npm run check:api` | Builds the package and runs API Extractor over `dist/brazilian-utils.d.ts`: a public type without a doc comment, or a type the API refers to without exporting, fails. | -| `npm run check:commits` | Checks the commit messages since `origin/main` with commitlint (Conventional Commits). | -| `npm run check:lockfile` | Checks `package-lock.json` only resolves to the npm registry over HTTPS with integrity hashes (lockfile-lint). | +| Command | What it does | +| ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `npm run check` | Runs `vp check`: format check, lint and type-check together. Run this before opening a PR. | +| `npm run check:fix` | Same as above, but auto-fixes what it can. | +| `npm run format` / `npm run format:check` | Formats the codebase / checks formatting with `vp fmt`. | +| `npm run lint` / `npm run lint:fix` | Lints the codebase with `vp lint`. | +| `npm run test` | Runs the unit test suite with `vp test`. | +| `npm run test:coverage` | Runs tests with coverage (`vp test run --coverage`). | +| `npm run test:bun` | Runs the test suite on [Bun](https://bun.sh) (`bun test src`). | +| `npm run test:deno` | Runs the test suite on [Deno](https://deno.com) (`deno test`). | +| `npm run test:live` | Runs the live CEP-provider test against the real network (`RUN_LIVE_CEP_TESTS=1 vp test src/get-address-info-by-cep/get-address-info-by-cep.test.ts`); not part of the regular test run, only of the scheduled `Live tests` workflow. | +| `npm run test:chrome-browser`, `npm run test:firefox-browser`, `npm run test:edge-browser`, `npm run test:safari-browser` | Runs the test suite in real browsers via `vp test --browser.enabled`. | +| `npm run build` | Builds the library for publishing with `vp pack` (also runs attw and publint over the built output). | +| `npm run build:data` | Regenerates the datasets under `src/_internals/constants` from the IBGE/CONCLA sources (`scripts/data.ts`); run by the scheduled `Update datasets` workflow. | +| `npm run build:llms` | Regenerates `docs/llms.txt` and `docs/llms-full.txt` from the docs (`scripts/llms.ts`); CI fails if they're out of date. | +| `npm run check:dependencies` | Fails if `package.json` declares any runtime `dependencies` (this package ships zero by design). | +| `npm run check:duplication` | Runs [jscpd](https://jscpd.dev) over `src` and `scripts`; any copy-pasted block of 5+ lines / 50+ tokens fails. | +| `npm run check:unused` | Runs [knip](https://knip.dev): unused files, exports, types and dependencies fail. | +| `npm run test:mutation` | Runs [Stryker](https://stryker-mutator.io) mutation tests (`stryker run`); pass `-- --mutate src//.ts` for one file. | +| `npm run check:api` | Builds the package and runs API Extractor over `dist/brazilian-utils.d.ts`: a public type without a doc comment, a type the API refers to without exporting, or a public signature that differs from the committed baseline `reports/api/brazilian-utils.api.md`, fails. | +| `npm run check:api:update` | Rewrites the committed API Extractor baseline `reports/api/brazilian-utils.api.md` from the current build; run it when a public signature changes on purpose and commit the new report. | +| `npm run check:commits` | Checks the commit messages since `origin/main` with commitlint (Conventional Commits). | +| `npm run check:lockfile` | Checks `package-lock.json` only resolves to the npm registry over HTTPS with integrity hashes (lockfile-lint). | Before opening a pull request, make sure `npm run check` and `npm run test` both pass locally. If your change touches runtime behavior, also consider running the Bun/Deno scripts above. The library is @@ -110,8 +115,12 @@ When an exported function has a source to credit, list the authoritative source `@see Official:` (a law, regulator, standard body or government dataset), followed by one `@see Based on:` line for every third-party implementation, mirror dataset or reference test vector the code actually relied on (a GitHub repo, a blog article, a community CSV/JSON mirror, -and so on), one `@see` per line. A utility with no located source of either kind (e.g. -`capitalize`, `formatCurrency`) can be left without an `@see` block. See +and so on), one `@see` per line. A regulator's own repository counts as `Official:` even though it +is a GitHub URL: `https://github.com/bacen/pix-api` is the Banco Central publishing the normative +Pix/SPI specification, not a third party reimplementing it. Put the URL alone on the `@see` line +and the description on the lines below it. Every utility in the package currently has at least one +`@see`; if you add one whose behaviour is a plain convention with no locatable source, say so in +prose in the JSDoc instead of inventing a citation. See `src/is-valid-certidao/is-valid-certidao.ts` and `src/is-valid-cei/is-valid-cei.ts` for the style. Shared helpers used by multiple utilities live under `src/_internals/`. Check there before @@ -212,10 +221,14 @@ pull request so the CI result is not a surprise. [API Extractor](https://api-extractor.com) runs over the bundled `dist/brazilian-utils.d.ts` in CI (`npm run check:api`). It fails when a type the public API refers to is not itself exported (a -consumer could not name it) and when an exported function, type or class has no doc comment. The -report it writes lands in the ignored `reports/api/` folder and is not committed: the public -signatures are pinned by the `describe(" types")` blocks in the tests, and the -`src/index.test.ts` export map catches an export that goes missing. +consumer could not name it) and when an exported function, type or class has no doc comment. It +also compares the public API against the reviewed baseline committed at +`reports/api/brazilian-utils.api.md` (the only file of the ignored `reports/` folder that is +committed) and fails when the two differ, so a change to a public signature has to be reviewed in +the diff of that report: run `npm run check:api:update` to write the new baseline and commit it +along with the change. On top of that, the public signatures are pinned by the +`describe(" types")` blocks in the tests, and the `src/index.test.ts` export map catches an +export that goes missing. ## Supply chain diff --git a/README.md b/README.md index 2423b6d2..0afabd35 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@ [📖 Documentation](https://brazilian-utils.com.br/#/getting-started) -[![npm version](https://img.shields.io/npm/v/@brazilian-utils/brazilian-utils.svg)](https://www.npmjs.com/package/@brazilian-utils/brazilian-utils) [![Downloads per month](https://img.shields.io/npm/dm/@brazilian-utils/brazilian-utils.svg)](https://www.npmjs.com/package/@brazilian-utils/brazilian-utils) [![License: MIT](https://img.shields.io/github/license/brazilian-utils/javascript.svg)](LICENSE) -[![Zero dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)](CONTRIBUTING.md#zero-runtime-dependencies) [![Bundle size](https://img.shields.io/bundlephobia/minzip/@brazilian-utils/brazilian-utils?label=isValidCpf%20import%20%3C%201%20KB&color=brightgreen)](docs/getting-started.md#bundle-size) [![Tree-shakeable](https://badgen.net/bundlephobia/tree-shaking/@brazilian-utils/brazilian-utils)](docs/getting-started.md#bundle-size) [![TypeScript](https://img.shields.io/npm/types/@brazilian-utils/brazilian-utils)](https://www.npmjs.com/package/@brazilian-utils/brazilian-utils) +[![npm version](https://img.shields.io/npm/v/@brazilian-utils/brazilian-utils.svg)](https://www.npmjs.com/package/@brazilian-utils/brazilian-utils) [![Downloads per month](https://img.shields.io/npm/dm/@brazilian-utils/brazilian-utils.svg)](https://www.npmjs.com/package/@brazilian-utils/brazilian-utils) [![License: MIT](https://img.shields.io/github/license/brazilian-utils/javascript.svg)](https://github.com/brazilian-utils/javascript/blob/main/LICENSE) +[![Zero dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)](https://github.com/brazilian-utils/javascript/blob/main/CONTRIBUTING.md#zero-runtime-dependencies) [![Bundle size](https://img.shields.io/bundlephobia/minzip/@brazilian-utils/brazilian-utils?label=isValidCpf%20import%20%3C%201%20KB&color=brightgreen)](https://brazilian-utils.com.br/#/getting-started?id=bundle-size) [![Tree-shakeable](https://badgen.net/bundlephobia/tree-shaking/@brazilian-utils/brazilian-utils)](https://brazilian-utils.com.br/#/getting-started?id=bundle-size) [![TypeScript](https://img.shields.io/npm/types/@brazilian-utils/brazilian-utils)](https://www.npmjs.com/package/@brazilian-utils/brazilian-utils) [![Build Status](https://github.com/brazilian-utils/javascript/actions/workflows/build.yml/badge.svg?branch=main)](https://github.com/brazilian-utils/javascript/actions/workflows/build.yml?query=branch%3Amain) [![Tests](https://github.com/brazilian-utils/javascript/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/brazilian-utils/javascript/actions/workflows/tests.yml?query=branch%3Amain) [![codecov](https://codecov.io/gh/brazilian-utils/javascript/branch/main/graph/badge.svg)](https://codecov.io/gh/brazilian-utils/javascript) [![Mutation tests](https://github.com/brazilian-utils/javascript/actions/workflows/mutation.yml/badge.svg?branch=main)](https://github.com/brazilian-utils/javascript/actions/workflows/mutation.yml?query=branch%3Amain) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/brazilian-utils/javascript/badge)](https://scorecard.dev/viewer/?uri=github.com/brazilian-utils/javascript) @@ -19,7 +19,9 @@ - [Getting Started](#getting-started) - [Why Brazilian Utils](#why-brazilian-utils) - [Installation](#installation) + - [Runtime support](#runtime-support) - [Usage](#usage) + - [Development](#development) - [Contributors](#contributors) - [License](#license) @@ -32,7 +34,7 @@ Brazilian Utils is a library focused on solving problems that we face daily in t ## Why Brazilian Utils - **Zero runtime dependencies.** Nothing else lands in your `node_modules` or in your bundle. -- **Tree-shakeable, down to the function.** `import { isValidCpf }` costs under 1 KB; every util is also its own subpath entry (`@brazilian-utils/brazilian-utils/get-cities`) for the heavy ones. +- **Tree-shakeable, down to the function.** `import { isValidCpf }` costs about 1.4 KB minified (0.8 KB gzipped); every util is also its own subpath entry (`@brazilian-utils/brazilian-utils/get-cities`) for the heavy ones. - **Runs everywhere.** Node.js `^20.19.0 || >=22.12.0`, Bun, Deno and evergreen browsers, tested in CI on every one of them. - **Written in TypeScript.** Types ship with the package; the public API is tracked by an API report so nothing changes silently. - **Validated against the official rules.** Every validator cites the specification, law or dataset it implements (`@see` in the docs), and the test suite is mutation-tested, not just covered. @@ -88,7 +90,7 @@ isValidCpf("1232454233345"); // false You can check a list of utilities [by clicking here](https://brazilian-utils.com.br/#/utilities). -- The package is tree-shakeable. Every util is also available as its own subpath (e.g. `@brazilian-utils/brazilian-utils/get-cities`) so you can lazy-load the few heavy ones. See [Bundle size](docs/getting-started.md#bundle-size). +- The package is tree-shakeable. Every util is also available as its own subpath (e.g. `@brazilian-utils/brazilian-utils/get-cities`) so you can lazy-load the few heavy ones. See [Bundle size](https://brazilian-utils.com.br/#/getting-started?id=bundle-size). ## Development @@ -174,4 +176,4 @@ This project follows the [all-contributors](https://github.com/kentcdodds/all-co ## License -[MIT](LICENSE) +[MIT](https://github.com/brazilian-utils/javascript/blob/main/LICENSE) diff --git a/docs/getting-started.md b/docs/getting-started.md index 8027214e..092cd612 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -5,7 +5,7 @@ Brazilian Utils is a library focused on solving problems that we face daily in t ## Why Brazilian Utils - **Zero runtime dependencies.** Nothing else lands in your `node_modules` or in your bundle. -- **Tree-shakeable, down to the function.** `import { isValidCpf }` costs under 1 KB; every util is also its own subpath entry (`@brazilian-utils/brazilian-utils/get-cities`) for the heavy ones. +- **Tree-shakeable, down to the function.** `import { isValidCpf }` costs about 1.4 KB minified (0.8 KB gzipped); every util is also its own subpath entry (`@brazilian-utils/brazilian-utils/get-cities`) for the heavy ones. - **Runs everywhere.** Node.js `^20.19.0 || >=22.12.0`, Bun, Deno and evergreen browsers, tested in CI on every one of them. - **Written in TypeScript.** Types ship with the package; the public API is tracked by an API report so nothing changes silently. - **Validated against the official rules.** Every validator cites the specification, law or dataset it implements (`@see` in the docs), and the test suite is mutation-tested, not just covered. @@ -63,19 +63,19 @@ You can check a list of utilities [by clicking here](utilities.md). ## Bundle size -The package is tree-shakeable: importing one util from the root pulls in only that util's code, not the rest of the library. `isValidCpf`, for example, adds roughly 0.7 KB minified to your bundle. A bundler that supports tree-shaking (webpack, Rollup, esbuild, Vite, etc.) drops every other util. +The package is tree-shakeable: importing one util from the root pulls in only that util's code, not the rest of the library. `isValidCpf`, for example, adds roughly 1.4 KB minified (0.8 KB gzipped) to your bundle. A bundler that supports tree-shaking (webpack, Rollup, esbuild, Vite, etc.) drops every other util. A handful of utils are the exception: each embeds an official dataset, so it weighs far more than every other util combined. These are their single-import sizes, minified and gzipped: | Util | Dataset | Minified | Gzipped | | --- | --- | --- | --- | -| `getMunicipalities` · `getMunicipalityByCode` · `getMunicipality` | 5571 IBGE municipalities, with names and codes | 156 KB | 50 KB | -| `getCities` | 5571 IBGE municipality names | 153 KB | 49 KB | -| `isValidNcm` | NCM (Nomenclatura Comum do Mercosul) codes | 113 KB | 24 KB | -| `isValidCbo` · `getCbo` | CBO 2002 occupation titles | 110 KB | 27 KB | -| `isValidCnae` · `getCnae` | CNAE 2.3 subclasses | 93 KB | 21 KB | -| `isValidCfop` · `getCfop` | CFOP operation descriptions | 55 KB | 5.4 KB | -| `getBanks` · `getBankByCode` | Banco Central STR participants (COMPE + ISPB) | 28 KB | 7.3 KB | +| `getMunicipalities` · `getMunicipalityByCode` · `getMunicipality` | 5571 IBGE municipalities, with names and codes | 154.9 - 156.5 KB | 50.3 - 50.4 KB | +| `getCities` | 5571 IBGE municipality names | 154.2 KB | 49.8 KB | +| `isValidNcm` | NCM (Nomenclatura Comum do Mercosul) codes | 114.1 KB | 24.6 KB | +| `isValidCbo` · `getCbo` | CBO 2002 occupation titles | 119.1 KB | 30.6 KB | +| `isValidCnae` · `getCnae` | CNAE-Subclasses 2.3 | 93.9 KB | 21.2 KB | +| `isValidCfop` · `getCfop` | CFOP operation descriptions | 68.9 KB | 6.9 KB | +| `getBanks` · `getBankByCode` | Banco Central STR participants (COMPE + ISPB) | 38.3 - 38.6 KB | 9.5 - 9.7 KB | Importing any of them from the root, even alongside a single small util, pulls that whole dataset into your main bundle, because this package ships as a single ESM module: a dynamic `import()` of the root (`await import('@brazilian-utils/brazilian-utils')`) still resolves to that same one file, so it can't be split out on its own. A bundler doing code-splitting needs a separate module to split *into*. @@ -97,4 +97,4 @@ getMunicipalityByCode('3550308'); Every util is available this way, as `@brazilian-utils/brazilian-utils/` (kebab-case, matching the function name: `isValidCpf` → `is-valid-cpf`), for the same lazy-loading/code-splitting reason. -Pick one style per util in a given app: a bundler treats the root import and the subpath import as two unrelated modules, so importing `getCities` from both the root *and* `/get-cities` in the same app bundles the 153 KB city table twice, once in each module's own output. +Pick one style per util in a given app: a bundler treats the root import and the subpath import as two unrelated modules, so importing `getCities` from both the root *and* `/get-cities` in the same app bundles the 154.2 KB city table twice, once in each module's own output. diff --git a/docs/llms-full.txt b/docs/llms-full.txt index f4e491e2..9c57aa14 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -5,6 +5,7 @@ ## Table of contents - [Getting Started](#getting-started) + - [Why Brazilian Utils](#why-brazilian-utils) - [Installation](#installation) - [Runtime support](#runtime-support) - [Usage](#usage) @@ -25,13 +26,14 @@ - [generateBoleto](#generateboleto) - [getBoletoInfo](#getboletoinfo) - [isValidPixKey](#isvalidpixkey) - - [parsePixKey](#parsepixkey) + - [getPixKeyInfo](#getpixkeyinfo) - [isValidPixPayload](#isvalidpixpayload) - - [parsePixPayload](#parsepixpayload) + - [getPixPayloadInfo](#getpixpayloadinfo) - [generatePixPayload](#generatepixpayload) - [isValidNfeKey](#isvalidnfekey) - [formatNfeKey](#formatnfekey) - [parseNfeKey](#parsenfekey) + - [getNfeKeyInfo](#getnfekeyinfo) - [isValidEmail](#isvalidemail) - [isValidPhone](#isvalidphone) - [formatPhone](#formatphone) @@ -43,6 +45,7 @@ - [getAreaCodesByState](#getareacodesbystate) - [isValidLicensePlate](#isvalidlicenseplate) - [isValidRenavam](#isvalidrenavam) + - [generateRenavam](#generaterenavam) - [isValidPis](#isvalidpis) - [formatPis](#formatpis) - [parsePis](#parsepis) @@ -60,6 +63,7 @@ - [isValidIban](#isvalidiban) - [formatIban](#formatiban) - [parseIban](#parseiban) + - [getIbanInfo](#getibaninfo) - [isValidCreditCard](#isvalidcreditcard) - [capitalize](#capitalize) - [formatCurrency](#formatcurrency) @@ -89,6 +93,7 @@ - [generateLegalNature](#generatelegalnature) - [parseLegalNature](#parselegalnature) - [getLegalNatures](#getlegalnatures) + - [getLegalNaturesByCategory](#getlegalnaturesbycategory) - [getLegalNature](#getlegalnature) - [generatePhone](#generatephone) - [formatLicensePlate](#formatlicenseplate) @@ -103,6 +108,7 @@ - [isHoliday](#isholiday) - [isBusinessDay](#isbusinessday) - [addBusinessDays](#addbusinessdays) + - [subBusinessDays](#subbusinessdays) - [differenceInBusinessDays](#differenceinbusinessdays) - [convertDateToWords](#convertdatetowords) - [formatVoterId](#formatvoterid) @@ -111,25 +117,34 @@ - [parseVoterId](#parsevoterid) - [isValidCns](#isvalidcns) - [formatCns](#formatcns) + - [parseCns](#parsecns) - [isValidCertidao](#isvalidcertidao) - - [parseCertidao](#parsecertidao) - [formatCertidao](#formatcertidao) + - [parseCertidao](#parsecertidao) + - [getCertidaoInfo](#getcertidaoinfo) - [isValidCei](#isvalidcei) - [formatCei](#formatcei) + - [parseCei](#parsecei) - [isValidCno](#isvalidcno) - [formatCno](#formatcno) + - [parseCno](#parsecno) - [isValidCaepf](#isvalidcaepf) - [formatCaepf](#formatcaepf) + - [parseCaepf](#parsecaepf) - [isValidRegistroProfissional](#isvalidregistroprofissional) - [isValidVin](#isvalidvin) - [isValidCbo](#isvalidcbo) + - [parseCbo](#parsecbo) - [getCbo](#getcbo) - [isValidCnae](#isvalidcnae) - [formatCnae](#formatcnae) + - [parseCnae](#parsecnae) - [getCnae](#getcnae) - [isValidNcm](#isvalidncm) - [formatNcm](#formatncm) + - [parseNcm](#parsencm) - [isValidCfop](#isvalidcfop) + - [parseCfop](#parsecfop) - [getCfop](#getcfop) - [isValidCst](#isvalidcst) - [isValidCsosn](#isvalidcsosn) @@ -142,7 +157,7 @@ Brazilian Utils is a library focused on solving problems that we face daily in t ### Why Brazilian Utils - **Zero runtime dependencies.** Nothing else lands in your `node_modules` or in your bundle. -- **Tree-shakeable, down to the function.** `import { isValidCpf }` costs under 1 KB; every util is also its own subpath entry (`@brazilian-utils/brazilian-utils/get-cities`) for the heavy ones. +- **Tree-shakeable, down to the function.** `import { isValidCpf }` costs about 1.4 KB minified (0.8 KB gzipped); every util is also its own subpath entry (`@brazilian-utils/brazilian-utils/get-cities`) for the heavy ones. - **Runs everywhere.** Node.js `^20.19.0 || >=22.12.0`, Bun, Deno and evergreen browsers, tested in CI on every one of them. - **Written in TypeScript.** Types ship with the package; the public API is tracked by an API report so nothing changes silently. - **Validated against the official rules.** Every validator cites the specification, law or dataset it implements (`@see` in the docs), and the test suite is mutation-tested, not just covered. @@ -200,19 +215,19 @@ You can check a list of utilities [by clicking here](utilities.md). ### Bundle size -The package is tree-shakeable: importing one util from the root pulls in only that util's code, not the rest of the library. `isValidCpf`, for example, adds roughly 0.7 KB minified to your bundle. A bundler that supports tree-shaking (webpack, Rollup, esbuild, Vite, etc.) drops every other util. +The package is tree-shakeable: importing one util from the root pulls in only that util's code, not the rest of the library. `isValidCpf`, for example, adds roughly 1.4 KB minified (0.8 KB gzipped) to your bundle. A bundler that supports tree-shaking (webpack, Rollup, esbuild, Vite, etc.) drops every other util. A handful of utils are the exception: each embeds an official dataset, so it weighs far more than every other util combined. These are their single-import sizes, minified and gzipped: | Util | Dataset | Minified | Gzipped | | --- | --- | --- | --- | -| `getMunicipalities` · `getMunicipalityByCode` · `getMunicipality` | 5571 IBGE municipalities, with names and codes | 156 KB | 50 KB | -| `getCities` | 5571 IBGE municipality names | 153 KB | 49 KB | -| `isValidNcm` | NCM (Nomenclatura Comum do Mercosul) codes | 113 KB | 24 KB | -| `isValidCbo` · `getCbo` | CBO 2002 occupation titles | 110 KB | 27 KB | -| `isValidCnae` · `getCnae` | CNAE 2.3 subclasses | 93 KB | 21 KB | -| `isValidCfop` · `getCfop` | CFOP operation descriptions | 55 KB | 5.4 KB | -| `getBanks` · `getBankByCode` | Banco Central STR participants (COMPE + ISPB) | 28 KB | 7.3 KB | +| `getMunicipalities` · `getMunicipalityByCode` · `getMunicipality` | 5571 IBGE municipalities, with names and codes | 154.9 - 156.5 KB | 50.3 - 50.4 KB | +| `getCities` | 5571 IBGE municipality names | 154.2 KB | 49.8 KB | +| `isValidNcm` | NCM (Nomenclatura Comum do Mercosul) codes | 114.1 KB | 24.6 KB | +| `isValidCbo` · `getCbo` | CBO 2002 occupation titles | 119.1 KB | 30.6 KB | +| `isValidCnae` · `getCnae` | CNAE-Subclasses 2.3 | 93.9 KB | 21.2 KB | +| `isValidCfop` · `getCfop` | CFOP operation descriptions | 68.9 KB | 6.9 KB | +| `getBanks` · `getBankByCode` | Banco Central STR participants (COMPE + ISPB) | 38.3 - 38.6 KB | 9.5 - 9.7 KB | Importing any of them from the root, even alongside a single small util, pulls that whole dataset into your main bundle, because this package ships as a single ESM module: a dynamic `import()` of the root (`await import('@brazilian-utils/brazilian-utils')`) still resolves to that same one file, so it can't be split out on its own. A bundler doing code-splitting needs a separate module to split *into*. @@ -234,13 +249,13 @@ getMunicipalityByCode('3550308'); Every util is available this way, as `@brazilian-utils/brazilian-utils/` (kebab-case, matching the function name: `isValidCpf` → `is-valid-cpf`), for the same lazy-loading/code-splitting reason. -Pick one style per util in a given app: a bundler treats the root import and the subpath import as two unrelated modules, so importing `getCities` from both the root *and* `/get-cities` in the same app bundles the 153 KB city table twice, once in each module's own output. +Pick one style per util in a given app: a bundler treats the root import and the subpath import as two unrelated modules, so importing `getCities` from both the root *and* `/get-cities` in the same app bundles the 154.2 KB city table twice, once in each module's own output. ## Utilities Here you will find all the utilities available for use. -> **Input handling:** no synchronous public function throws on `null`/`undefined` or a wrong-type value; the two network helpers, `getAddressInfoByCep` and `getCepInfoByAddress`, reject with their typed errors (see their sections). `isValid*` predicates return `false`; `isHoliday` returns `false`; `getHolidays` returns `[]`; `generateProcessoJuridico` returns `null`; `getMunicipality` returns `null` for a malformed/unmatched lookup. Every other `format*`/`parse*` function (including `capitalize`) returns an empty value of its return type: `""` for strings, `0` for `parseCurrency`. `formatCurrency` returns `""` for a non-finite number. +> **Input handling:** no synchronous public function throws on `null`/`undefined` or a wrong-type value; the two network helpers, `getAddressInfoByCep` and `getCepInfoByAddress`, reject with their typed errors (see their sections). `isValid*` predicates return `false`; `isHoliday` returns `false`; `getHolidays` returns `[]`; `getBoletoInfo` returns `null` for an invalid boleto; `generateProcessoJuridico` returns `null`; `getMunicipality` returns `null` for a malformed/unmatched lookup. Every other `format*`/`parse*` function returns an empty value of its return type: every `format*` function, `capitalize`, and the string-returning `parse*` functions (`parseBoleto`, `parseCaepf`, `parseCbo`, `parseCei`, `parseCep`, `parseCertidao`, `parseCfop`, `parseCnae`, `parseCnh`, `parseCno`, `parseCnpj`, `parseCns`, `parseCpf`, `parseIban`, `parseLegalNature`, `parseLicensePlate`, `parseNcm`, `parseNfeKey`, `parsePassport`, `parsePhone`, `parsePis`, `parseProcessoJuridico`, `parseVoterId`) return `""`; `parseCurrency` returns `0`; the structured readers — `getCertidaoInfo`, `getIbanInfo`, `getNfeKeyInfo`, `getPixKeyInfo`, `getPixPayloadInfo` — return `null`. `formatCurrency` returns `""` for a non-finite number and for a value that cannot be coerced to one (a symbol, a plain object, a null-prototype object); `null`, arrays and booleans go through `Number()` as in 2.3.0. The one exception to the promise above: an object created with `Object.create(null)` has no `toString`, so the `format*`/`parse*` helpers that read their input as text still throw a `TypeError` for it, exactly as they did in 2.3.0. ### isValidCpf @@ -255,7 +270,7 @@ isValidCpf('111 444 777 35'); // true (whitespace mask) ### formatCpf -Format CPF. `options.obfuscate` (part of `FormatCpfOptions`) hides the first 3 digits and the 2 check digits (`***.456.789-**`), the gov.br / Receita Federal display convention, applied after `pad`. +Format CPF. `options.pad` (part of `FormatCpfOptions`) left-pads the value with zeros up to the 11 slots of the pattern before masking (default `false`). `options.obfuscate` (same type) hides the first 3 digits and the 2 check digits (`***.456.789-**`), the gov.br / Receita Federal display convention, applied after `pad`. It is read for truthiness, the way `pad` is, so any truthy value obfuscates. ```javascript import { formatCpf } from '@brazilian-utils/brazilian-utils'; @@ -277,7 +292,7 @@ parseCpf('746.506.880-00'); // 74650688000 ### generateCpf -Generate a valid random CPF. +Generate a valid random CPF. Uses `Math.random()` internally, so it is not cryptographically secure. ```javascript import { generateCpf } from '@brazilian-utils/brazilian-utils' @@ -288,7 +303,7 @@ generateCpf('SP'); // the 9th digit is 8, the SP região fiscal code ### isValidCnpj -Check if CNPJ is valid. Supports both the numeric format (`version: 1`, default) and the alphanumeric format (`version: 2`), and accepts the usual mask characters and whitespace. Options are typed as `IsValidCnpjOptions`. +Check if CNPJ is valid. `options.version` (part of `IsValidCnpjOptions`) picks which format is accepted: `1` (default) the numeric-only format, `2` both the numeric and the alphanumeric one; any other value is read as `1`, the way `formatCnpj` and `parseCnpj` read it. The usual mask characters and whitespace are accepted in either version. Version `2` has no reserved-value list, because the Receita Federal manual defines none for the alphanumeric format: a repeated-character alphanumeric base (all `A`s, say) that passes the checksum is accepted, while the numeric reserved numbers are rejected under version `1`. ```javascript import { isValidCnpj } from '@brazilian-utils/brazilian-utils'; @@ -299,7 +314,7 @@ isValidCnpj('q0slfmbd7vx439', { version: 2 }); // true (lowercase alphanumeric) ### formatCnpj -Format CNPJ. `options.obfuscate` (part of `FormatCnpjOptions`) hides the first 2 digits and the 2 check digits (`**.345.678/0001-**`), the gov.br / Receita Federal display convention. It applies to both versions and comes after `pad`. +Format CNPJ. `options.pad` (part of `FormatCnpjOptions`) left-pads the value with zeros up to the 14 slots of the pattern before masking (default `false`). `options.version` (same type) picks which CNPJ format to read: `1` (default) numeric only, `2` alphanumeric. `options.obfuscate` hides the first 2 digits and the 2 check digits (`**.345.678/0001-**`), the gov.br / Receita Federal display convention. It applies to both versions and comes after `pad`, and is read for truthiness, the way `pad` is, so any truthy value obfuscates. ```javascript import { formatCnpj } from '@brazilian-utils/brazilian-utils'; @@ -312,7 +327,7 @@ formatCnpj('12345678000195', { obfuscate: true }); // **.345.678/0001-** ### parseCnpj -Remove CNPJ formatting, return a normalized value, and cap the result to 14 characters. Options are typed as `ParseCnpjOptions`. +Remove CNPJ formatting, return a normalized value, and cap the result to 14 characters. `options.version` (part of `ParseCnpjOptions`) picks which CNPJ format to normalize: `1` (default) keeps digits only, `2` keeps letters and digits, so an alphanumeric CNPJ survives the round trip. ```javascript import { parseCnpj } from '@brazilian-utils/brazilian-utils'; @@ -323,7 +338,7 @@ parseCnpj('12.OUT.345/0001-99', { version: 2 }); // 12OUT345000199 ### isValidCep -Check if CEP ([brazilian postal code](https://en.wikipedia.org/wiki/C%C3%B3digo_de_Endere%C3%A7amento_Postal)) is valid. Accepts both `string` and `number` input; any spaces, dots and hyphens around/between the 8 digits are ignored, but any other character, a letter in particular, makes the value invalid. +Check if CEP ([brazilian postal code](https://en.wikipedia.org/wiki/C%C3%B3digo_de_Endere%C3%A7amento_Postal)) is valid. Accepts both `string` and `number` input, but a CEP that starts with `0` has to be passed as a string, since a number cannot keep the leading zero (`isValidCep(1310100)` is `false`, `isValidCep('01310100')` is `true`); any spaces, dots and hyphens around/between the 8 digits are ignored, but any other character, a letter in particular, makes the value invalid. ```javascript import { isValidCep } from '@brazilian-utils/brazilian-utils'; @@ -339,18 +354,20 @@ isValidCep('12345'); // false (invalid length) ### generateCnpj -Generate a valid random CNPJ. +Generate a valid random CNPJ. Uses `Math.random()` internally, so it is not cryptographically secure. The first argument is either the version, as before, or a `GenerateCnpjOptions` object with the same `version` plus `branch`, the "número de ordem" (filial) block in positions 9 to 12: an integer from 1 to 9999 written zero padded to four characters, random by default. An invalid `branch` is ignored and a random block is used, and the block stays numeric on the alphanumeric version. ```javascript import { generateCnpj } from '@brazilian-utils/brazilian-utils' generateCnpj(); generateCnpj(2); // alphanumeric CNPJ, e.g. 'Q0SLFMBD7VX439' +generateCnpj({ branch: 3 }); // ordem block '0003', e.g. '12345678000372' +generateCnpj({ version: 2, branch: 1 }); // alphanumeric CNPJ whose ordem block is '0001' ``` ### isValidBoleto -Check if boleto ([brazilian payment method](https://en.wikipedia.org/wiki/Boleto)) is valid. Supports both the 47 digit "cobrança bancária" boleto and the "boleto de arrecadação" (convênio/tributos): either its 48 digit linha digitável or its 44 digit barcode, both starting with `8`. +Check if boleto ([brazilian payment method](https://en.wikipedia.org/wiki/Boleto)) is valid. Supports both the 47 digit "cobrança bancária" boleto and the "boleto de arrecadação" (convênio/tributos): either its 48 digit linha digitável or its 44 digit barcode, both starting with `8`. One leniency is kept from 2.3.0: the código de moeda in position 4 of the cobrança bancária barcode is not checked, although Carta-Circular BCB nº 2.926/2000 fixes it at `9` (real), so a slip carrying any other moeda digit still validates. ```javascript import { isValidBoleto } from '@brazilian-utils/brazilian-utils'; @@ -361,7 +378,7 @@ isValidBoleto('846100000005246100291102005460339004695895061080'); // true (bole ### formatBoleto -Format a boleto number. The arrecadação (convênio/tributos) mask applies only to the 48 digit linha digitável starting with `8`; the 44 digit arrecadação barcode has no display grouping defined by FEBRABAN and keeps the "cobrança bancária" mask instead. +Format a boleto number. `options.pad` (part of `FormatBoletoOptions`) left-pads the value with zeros up to the number of slots in the pattern before masking (default `false`). The arrecadação (convênio/tributos) mask applies only to the 48 digit linha digitável starting with `8`; the 44 digit arrecadação barcode has no display grouping defined by FEBRABAN and keeps the "cobrança bancária" mask instead. ```javascript import { formatBoleto } from '@brazilian-utils/brazilian-utils'; @@ -384,7 +401,7 @@ parseBoleto('00190.00009 01149.718601 68524.522114 6 75860000102656'); // 001900 ### generateBoleto -Generate a valid random boleto. Pass `{ type: "arrecadacao" }` (typed as `GenerateBoletoOptions`) to generate a boleto de arrecadação instead of the default "bancario" (cobrança bancária) type. +Generate a valid random boleto. Pass `{ type: "arrecadacao" }` (typed as `GenerateBoletoOptions`) to generate a boleto de arrecadação instead of the default "bancario" (cobrança bancária) type. An arrecadação slip draws its segment from 1 to 7 (segment 9 is the banks' own) and its value identifier from all four values, `6` and `8` for an effective amount and `7` and `9` for a reference quantity, so both `hasEffectiveValue` branches of `getBoletoInfo` are reachable. ```javascript import { generateBoleto } from '@brazilian-utils/brazilian-utils'; @@ -395,7 +412,7 @@ generateBoleto({ type: 'arrecadacao' }); // "84610000000524610029110200546033900 ### getBoletoInfo -Extract information from a boleto (amount, expiration date, bank code). Accepts an optional `{ referenceDate }` (typed as `GetBoletoInfoOptions`) to resolve the "fator de vencimento" cycle as of a specific date instead of now (the factor's date-base cycle reset on 22/02/2025 per FEBRABAN). For a boleto de arrecadação, the result, typed as `BoletoInfo`, has no `bankCode`/`expirationDate` and instead carries `type: "arrecadacao"`, `segment`, `value` and `hasEffectiveValue`. +Extract information from a boleto (amount, expiration date, bank code). Returns `null` when `value` is not a valid boleto — `isValidBoleto` is checked first — so the result has to be narrowed before it is read. 2.3.0 returned `undefined` here; every getter of the package now answers an unresolved lookup with `null`, so only a strict `=== undefined` comparison is affected. Accepts an optional `{ referenceDate }` (typed as `GetBoletoInfoOptions`) to resolve the "fator de vencimento" cycle as of a specific date instead of now (the factor's date-base cycle reset on 22/02/2025 per FEBRABAN). Neither FEBRABAN nor the Banco Central publishes a way of telling an old cycle factor from a new cycle one, so every factor resolves to either of two dates 9000 days apart and `referenceDate` picks between them through the library's own safety windows: the same slip can resolve to the other candidate as time passes, so pass `referenceDate` explicitly whenever the answer has to stay stable. The cycle search never goes below the first cycle, so a `referenceDate` older than the scheme itself still resolves a factor to the oldest date that factor can denote rather than to one before the 07/10/1997 base date. For a boleto de arrecadação, the result, typed as `BoletoInfo`, still carries both keys but empty, `bankCode: ''` and `expirationDate: null`, since the slip has neither a bank code nor a fator de vencimento, and adds `type: "arrecadacao"`, `segment`, `value` and `hasEffectiveValue`. ```javascript import { getBoletoInfo } from '@brazilian-utils/brazilian-utils'; @@ -410,6 +427,8 @@ getBoletoInfo('00190000090114971860168524522114675860000102656', { getBoletoInfo('846100000005246100291102005460339004695895061080'); // { amount: 2461, expirationDate: null, bankCode: '', type: 'arrecadacao', segment: 4, value: 24.61, hasEffectiveValue: true } + +getBoletoInfo('invalid'); // null ``` ### isValidPixKey @@ -428,26 +447,26 @@ isValidPixKey('123.456.789-09', { accept: ['email', 'evp'] }); // false isValidPixKey('not a key'); // false ``` -### parsePixKey +### getPixKeyInfo -Identifies a Pix key and normalizes it to the canonical form the DICT expects inside a BR Code: 11 digit CPF, 14 character CNPJ, lowercased e-mail, E.164 mobile phone (a landline is not a Pix key) or lowercase UUID EVP. An 11 digit value that is valid both as a CPF and as a mobile phone is read as a CPF, unless it was written as a phone number (a `+55`/`0055` prefix or a DDD wrapped in parentheses). The CPF and the phone number are recognized by the way they are written, not only by the digits they carry, so surrounding text is not stripped away and `'abc123.456.789-09'` is not a CPF key. Returns `null` when the value is not a valid Pix key. The result is typed as `PixKey`. +Identifies a Pix key and normalizes it to the canonical form the DICT expects inside a BR Code: 11 digit CPF, 14 character CNPJ, lowercased e-mail, E.164 mobile phone (a landline is not a Pix key) or lowercase UUID EVP. An 11 digit value that is valid both as a CPF and as a mobile phone is read as a CPF, unless it was written as a phone number (a `+55`/`0055` prefix or a DDD wrapped in parentheses). The CPF and the phone number are recognized by the way they are written, not only by the digits they carry, so surrounding text is not stripped away and `'abc123.456.789-09'` is not a CPF key. An e-mail key is trimmed and lowercased, and one longer than the 77 characters the DICT allows is rejected. A value whose digits carry a valid CNPJ check digit is read as a CNPJ even when it starts with `0055`, since a phone key inside a BR Code always carries the `+55` prefix. Returns `null` when the value is not a valid Pix key. The result is typed as `PixKeyInfo`. ```javascript -import { parsePixKey } from '@brazilian-utils/brazilian-utils'; +import { getPixKeyInfo } from '@brazilian-utils/brazilian-utils'; -parsePixKey('123.456.789-09'); // { type: 'cpf', value: '12345678909' } -parsePixKey('Fulano@Example.COM '); // { type: 'email', value: 'fulano@example.com' } -parsePixKey('(11) 98765-4321'); // { type: 'phone', value: '+5511987654321' } -parsePixKey('71C7D9BE-4B85-4E43-9F1C-1F3B8B4E9A2D'); +getPixKeyInfo('123.456.789-09'); // { type: 'cpf', value: '12345678909' } +getPixKeyInfo('Fulano@Example.COM '); // { type: 'email', value: 'fulano@example.com' } +getPixKeyInfo('(11) 98765-4321'); // { type: 'phone', value: '+5511987654321' } +getPixKeyInfo('71C7D9BE-4B85-4E43-9F1C-1F3B8B4E9A2D'); // { type: 'evp', value: '71c7d9be-4b85-4e43-9f1c-1f3b8b4e9a2d' } -parsePixKey('(11) 3000-0000'); // null (a landline is not a Pix key) -parsePixKey('51998259765'); // { type: 'cpf', value: '51998259765' } (also a valid phone) -parsePixKey('+5551998259765'); // { type: 'phone', value: '+5551998259765' } +getPixKeyInfo('(11) 3000-0000'); // null (a landline is not a Pix key) +getPixKeyInfo('51998259765'); // { type: 'cpf', value: '51998259765' } (also a valid phone) +getPixKeyInfo('+5551998259765'); // { type: 'phone', value: '+5551998259765' } ``` ### isValidPixPayload -Check if a Pix BR Code payload (the string behind a Pix QR Code and behind "Pix copia e cola") is valid: well-formed TLV structure, the mandatory objects present, one of the "Merchant Account Information" templates carrying the `br.gov.bcb.pix` GUI with a key or a URL, a "Point of Initiation Method" object (`01`) that agrees with it (a key requires a static payload, so `01` is absent or `"11"`; a URL requires a dynamic one, so `01` is `"12"`), an amount (`54`) greater than zero in a static payload, and a matching CRC-16. The key itself is not checked against the DICT formats, use `isValidPixKey` for that. Payloads that carry the location in an Unreserved Template (IDs 80 to 99), as the "QR Code composto" of Pix Automático (Pix recorrente) does, are out of scope and reported as invalid. +Check if a Pix BR Code payload (the string behind a Pix QR Code and behind "Pix copia e cola") is valid: well-formed TLV structure, the mandatory objects present, one of the "Merchant Account Information" templates carrying the `br.gov.bcb.pix` GUI with a key or a URL, and a matching CRC-16. The "Point of Initiation Method" object (`01`) is advisory: the Manual do BR Code marks it optional and only assigns a meaning to the value `"12"` ("só pode ser utilizado uma vez"), so it may be absent from either shape and only a value outside `{"11", "12"}` makes the payload invalid. When a payload built around a key carries an amount (`54`), that amount must be greater than zero, unless the payload is a Pix Saque BR Code, i.e. unless it carries the ISPB of the "facilitador de serviço de saque" in sub-object 26-03 (`fss`) as §2.6 of the Pix manual prescribes; rejecting `"0"`/`"0.00"` without `fss` is a deliberate restriction of this library, not a rule of the manual. A `fss` written next to a PSP location makes the payload invalid: §2.7 of the Manual de Padrões para Iniciação do Pix maps the dynamic QR Code to exactly two sub-objects, `00` (GUI) and `25` (URL), and `fss` belongs to the static template of §2.6. The key itself is not checked against the DICT formats, use `isValidPixKey` for that. Unreserved Templates (IDs 80 to 99) are ignored: the "QR Code composto" of Pix Automático (Pix recorrente) writes its recurrence location in one of them, and when such a payload also carries a payment location in 26-25, as the composite example of the Pix manual does, it is accepted and read as an ordinary dynamic payload with the recurrence location dropped. Only a payload with no Pix template at all in IDs 26 to 51 is reported as invalid. ```javascript import { isValidPixPayload } from '@brazilian-utils/brazilian-utils'; @@ -460,29 +479,30 @@ isValidPixPayload( isValidPixPayload('00020126580014br.gov.bcb.pix...'); // false (broken CRC) ``` -### parsePixPayload +### getPixPayloadInfo -Parses a Pix BR Code payload into its fields. The payload is validated by `isValidPixPayload` first, so a malformed structure, a broken CRC or a missing mandatory object returns `null` instead of a partial result. A static payload comes back with `key`, a dynamic one with `url`. The result is typed as `PixPayload`; `pointOfInitiation` is typed as `PixPointOfInitiation` (`"static"` or `"dynamic"`). The merchant account information must carry exactly one of a key or a `url` (checked with the same PSP location rule as `generatePixPayload`), and the "Point of Initiation Method" object (`01`) must agree with it: a key belongs to a static payload (`01` absent or `"11"`) and a `url` to a dynamic one (`01` set to `"12"`), so any other pairing returns `null`. A static payload that states an amount must state one greater than zero (`54` set to `0.00` is reserved for the Pix Saque/Troco BR Code, which is out of scope), and in a dynamic payload the amount and the `txid` are ignored, as the manual mandates. Payloads whose location lives in an Unreserved Template (IDs 80 to 99, Pix Automático) are out of scope and return `null`. +Parses a Pix BR Code payload into its fields. The payload is validated by `isValidPixPayload` first, so a malformed structure, a broken CRC or a missing mandatory object returns `null` instead of a partial result. A static payload comes back with `key`, a dynamic one with `url`. The Pix key itself is not validated, since the manual allows a static QR Code built around a key that no longer exists in the DICT; key ownership is only settled at payment time. The "Additional Data Field Template" (ID 62) is mandatory in the BR Code table but optional in the EMV® specification it refers to, so it is accepted when absent. The lengths the manual reserves for the merchant name (25), the merchant city (15), the `txid` (25) and the Pix key field 26-01 (77) are generator side limits, enforced by `generatePixPayload` and not checked here, since payloads in the wild routinely overrun them. The result is typed as `PixPayloadInfo`; `pointOfInitiation` is always present and typed as `PixPointOfInitiation`, `"dynamic"` when the payload carries a PSP location or when the "Point of Initiation Method" object (`01`) is `"12"`, `"static"` otherwise. The merchant account information must carry exactly one of a key or a `url` (checked with the same PSP location rule as `generatePixPayload`); `01` itself is advisory, so it may be absent from either shape and only a value outside `{"11", "12"}` returns `null`. When a payload built around a key carries an amount, that amount must be greater than zero, unless the payload is a Pix Saque BR Code: §2.6 of the Pix manual puts the ISPB of the "facilitador de serviço de saque" in sub-object 26-03 (`fss`), which comes back as `withdrawalFacilitator`, and `54` set to `"0"` or `"0.00"` is accepted alongside it. Rejecting a zero amount without `fss` is a deliberate restriction of this library, not a rule of the manual. A `fss` written next to a PSP location returns `null`: §2.7 of the Manual de Padrões para Iniciação do Pix maps the dynamic QR Code to exactly two sub-objects, `00` (GUI) and `25` (URL), and `fss` belongs to the static template of §2.6. When the payload carries a PSP location the amount and the `txid` are ignored, as the manual mandates. Unreserved Templates (IDs 80 to 99) are ignored: a "QR Code composto" of Pix Automático that also carries a payment location in 26-25 is parsed as an ordinary dynamic payload and its recurrence location is dropped, so a consumer that has to tell the two apart cannot rely on this parser. Only a payload with no Pix template at all in IDs 26 to 51 returns `null`. ```javascript -import { parsePixPayload } from '@brazilian-utils/brazilian-utils'; +import { getPixPayloadInfo } from '@brazilian-utils/brazilian-utils'; -parsePixPayload( +getPixPayloadInfo( '00020126580014br.gov.bcb.pix0136123e4567-e12b-12d1-a456-426655440000' + '5204000053039865802BR5913Fulano de Tal6008BRASILIA62070503***63041D3D' ); // { -// key: '123e4567-e12b-12d1-a456-426655440000', // merchantName: 'Fulano de Tal', -// merchantCity: 'BRASILIA' +// merchantCity: 'BRASILIA', +// pointOfInitiation: 'static', +// key: '123e4567-e12b-12d1-a456-426655440000' // } ``` ### generatePixPayload -Generates the payload of a Pix BR Code. Exactly one of `params.key` or `params.url` must be given (part of `GeneratePixPayloadParams`); `null` is returned when both or neither are given. `url` must be a PSP location as the Bacen manual defines it: a host name with a path, without a scheme (`pix.example.com/qr/v2/1234`); a dynamic payload cannot carry `amount` or `txid`, which belong to the PSP location, and an `amount` that rounds to `0.00` is rejected. +Generates the payload of a Pix BR Code. Exactly one of `params.key` or `params.url` must be given (part of `GeneratePixPayloadOptions`); `null` is returned when both or neither are given. `url` must be a PSP location as the Bacen manual defines it: a host name with a path, without a scheme (`pix.example.com/qr/v2/1234`); a dynamic payload cannot carry `amount` or `txid`, which belong to the PSP location. The amount is written with the two decimal places the BR Code takes, so one that rounds to `0.00` and one that does not survive that round trip (`0.005`, `123.456`) are both rejected rather than written as a different sum. The Pix Saque BR Code, which announces the `fss` of sub-object 26-03, is parsed by `getPixPayloadInfo` but not generated here. -When `params.key` is given, it is normalized to its DICT canonical form by `parsePixKey` and the payload is static. When `params.url` is given instead (the PSP location, without a URL scheme, e.g. `"pix.example.com/qr/v2/1234"`), the payload is dynamic per the Manual de Padrões para Iniciação do Pix: the URL takes the key's place in the "Merchant Account Information" template and the "Point of Initiation Method" object is set to dynamic (`12`); `params.url` can be at most 77 characters. `merchantName`, `merchantCity` and `description` are folded to printable ASCII (accents dropped) and truncated to what the BR Code allows. `parsePixPayload` already parses both shapes, so `parsePixPayload(generatePixPayload({ url, ... }))` round-trips. +When `params.key` is given, it is normalized to its DICT canonical form by `getPixKeyInfo` and the payload is static. When `params.url` is given instead (the PSP location, without a URL scheme, e.g. `"pix.example.com/qr/v2/1234"`), the payload is dynamic per the Manual de Padrões para Iniciação do Pix: the URL takes the key's place in the "Merchant Account Information" template and the "Point of Initiation Method" object is set to dynamic (`12`); `params.url` can be at most 77 characters. `merchantName`, `merchantCity` and `description` are folded to printable ASCII (accents dropped) and truncated to what the BR Code allows. `getPixPayloadInfo` already parses both shapes, so `getPixPayloadInfo(generatePixPayload({ url, ... }))` round-trips. ```javascript import { generatePixPayload } from '@brazilian-utils/brazilian-utils'; @@ -507,46 +527,75 @@ generatePixPayload({ merchantName: 'Fulano', merchantCity: 'Brasília' }); // nu ### isValidNfeKey -Check if a DF-e (Documento Fiscal eletrônico) access key (chave de acesso) is valid. It covers every document that shares the same 44 digit layout: NF-e (modelo 55), NFC-e (modelo 65), CT-e (modelo 57), MDF-e (modelo 58) and CT-e OS (modelo 67, the Conhecimento de Transporte Eletrônico para Outros Serviços of the [Ajuste SINIEF 09/07](https://www.confaz.fazenda.gov.br/legislacao/ajustes/2007/aj_009_07)). Accepts whitespace between digit groups (the common display mask) and the `NFe` prefix found in the `Id` attribute of the document's XML. The emission type (`tpEmis`) must be one of the codes the MOC assigns, 1 to 7 or 9; 8 is not assigned and makes the key invalid. +Check if a DF-e (Documento Fiscal eletrônico) access key (chave de acesso) is valid. It covers every document whose access key is the same 44 digit string: NF-e (modelo 55), NFC-e (65), CT-e (57, the Conhecimento de Transporte Eletrônico instituted by the cláusula primeira of the [Ajuste SINIEF 09/07](https://www.confaz.fazenda.gov.br/legislacao/ajustes/2007/AJ_009_07)), MDF-e (58), CT-e OS (67, the Conhecimento de Transporte Eletrônico para Outros Serviços instituted by the cláusula primeira of the [Ajuste SINIEF 36/19](https://www.confaz.fazenda.gov.br/legislacao/ajustes/2019/AJ036_19)), GTV-e (64, the CT-e Guia de Transporte de Valores instituted by the cláusula primeira of the [Ajuste SINIEF 03/20](https://www.confaz.fazenda.gov.br/legislacao/ajustes/2020/ajuste-sinief-03-20)), BP-e (63), NF3e (66) and NFCom (62). The CF-e-SAT (59) is out: its 44 position "chave de consulta" is composed differently. The 44 digits may be split into the printed groups of 4 by whitespace, `.`, `-` or `/`, a run of them between two groups included, the same interchangeable mask `isValidCpf` and `isValidCnpj` accept; a separator inside a group of 4, or any other character, is rejected instead of being stripped. The `NFe`, `CTe`, `MDFe`, `BPe`, `NF3e` and `NFCom` prefixes found in the `Id` attribute of the document's XML are stripped before that check, along with any whitespace between the prefix and the first group. + +The emission type (`tpEmis`) is checked against the codes the MOC of that model assigns, so the accepted set changes with the model: 1 to 7 and 9 for NF-e and NFC-e, `{1, 3, 4, 5, 7, 8}` for the CT-e, `{1, 5, 7, 8}` for the CT-e OS, `{1, 2, 7, 8}` for the GTV-e, `{1, 2, 3}` for the MDF-e and `{1, 2}` for the BP-e, the NF3e and the NFCom. Code 8, the authorização pela SVC-SP, is assigned by the [CT-e MOC 4.00](https://dfe-portal.svrs.rs.gov.br/CTE/Documentos) only, never by the NF-e one; the domains of the [BP-e](https://dfe-portal.svrs.rs.gov.br/BPE/Documentos), the [NF3e](https://dfe-portal.svrs.rs.gov.br/NF3e/Documentos) and the [NFCom](https://dfe-portal.svrs.rs.gov.br/NFCOM/Documentos) come from their own manuals. For NF-e and NFC-e the numeric code is also checked against rule B03-10 of the NF-e MOC, which forbids the twenty repeated and sequential `cNF` values it lists and a `cNF` equal to the document number. A document number of all zeros is turned down for every model, following the leiaute rather than a choice of this library: `tiposBasico_v4.00.xsd` of the [NF-e schema package](https://dfe-portal.svrs.rs.gov.br/NFE/Documentos) types `nNF` as `TNF`, whose pattern is `[1-9]{1}[0-9]{0,8}`, and the Anexo I of every other model repeats the same regex for its own number field. ```javascript import { isValidNfeKey } from '@brazilian-utils/brazilian-utils'; isValidNfeKey('35170458716523000119550010000000121000123458'); // true (NF-e, SP) isValidNfeKey('NFe35170458716523000119550010000000121000123458'); // true (XML Id prefix) +isValidNfeKey('CTe35170458716523000119570010000000128000123452'); // true (CT-e authorised by the SVC-SP) isValidNfeKey('3517 0458 7165 2300 0119 5500 1000 0000 1210 0012 3458'); // true (masked) +isValidNfeKey('3517.0458.7165.2300.0119.5500.1000.0000.1210.0012.3458'); // true (any of the mask characters) +isValidNfeKey('351 70458716523000119550010000000121000123458'); // false (a separator inside a group of 4) isValidNfeKey('99170458716523000119550010000000121000123458'); // false (invalid cUF) -isValidNfeKey('35170458716523000119550010000000128000123455'); // false (tpEmis 8 is not assigned) +isValidNfeKey('35170458716523000119550010000000128000123455'); // false (the NF-e MOC does not assign tpEmis 8) +isValidNfeKey('35170458716523000119550010000000121000000003'); // false (cNF 00000000, rule B03-10) ``` ### formatNfeKey -Format a DF-e (NF-e, NFC-e, CT-e, MDF-e or CT-e OS) access key into groups of 4 digits separated by spaces, the common display form printed on the DANFE. +Format a DF-e (Documento Fiscal eletrônico) access key into groups of 4 digits separated by spaces, the form every auxiliary document prints it in: the DANFE of the NF-e and the NFC-e, the DACTE of the CT-e, the CT-e OS and the GTV-e, the DAMDFE of the MDF-e, the DABPE of the BP-e, the DANF3E of the NF3e and the DANFE-COM of the NFCom. Like every formatter of this package, the value is read for its digits and grouped as far as they go, so a masked or partial key still being typed is grouped progressively, and anything without a digit (an object, `true`, an object created with `Object.create(null)`) gives `''` instead of throwing. Use `isValidNfeKey` to check a key. `options.pad` (part of `FormatNfeKeyOptions`) left pads the value with zeros up to the 44 digits of a complete access key (default `false`). The parameter is typed as a string because 44 digits are more than a JavaScript number can hold exactly; at runtime a number is read as the string of its digits, like in every formatter of this package. ```javascript import { formatNfeKey } from '@brazilian-utils/brazilian-utils'; formatNfeKey('35170458716523000119550010000000121000123458'); // '3517 0458 7165 2300 0119 5500 1000 0000 1210 0012 3458' + +formatNfeKey('12345'); // '1234 5' + +formatNfeKey('12345', { pad: true }); +// '0000 0000 0000 0000 0000 0000 0000 0000 0000 0001 2345' ``` ### parseNfeKey -Parses a DF-e access key into its fields (state, year, month, taxId, model, series, number, emissionType, code, checkDigit). Accepts the same input forms as `isValidNfeKey` and returns `null` when the key is not valid. The result is typed as `NfeKey`. +Remove the formatting of a DF-e access key (chave de acesso), keep only digits, and cap the result to 44 digits. The `NFe`, `CTe`, `MDFe`, `BPe`, `NF3e` and `NFCom` prefixes the `Id` attribute of the document XML puts in front of the key are stripped first, since `NF3e` carries a digit of its own; use `isValidNfeKey` to check the key and `getNfeKeyInfo` to read its fields. ```javascript import { parseNfeKey } from '@brazilian-utils/brazilian-utils'; -parseNfeKey('35170458716523000119550010000000121000123458'); -// { state: 'SP', year: 2017, month: 4, taxId: '58716523000119', model: '55', +parseNfeKey('3517 0458 7165 2300 0119 5500 1000 0000 1210 0012 3458'); +// '35170458716523000119550010000000121000123458' + +parseNfeKey('NFe35170458716523000119550010000000121000123458'); +// '35170458716523000119550010000000121000123458' +``` + +### getNfeKeyInfo + +Parses a DF-e access key into its fields (stateCode, year, month, taxId, model, series, number, emissionType, code, checkDigit). Accepts the same input forms as `isValidNfeKey` and returns `null` when the key is not valid. The result is typed as `NfeKeyInfo`, whose `model` is an `NfeKeyModel`. NFCom (`'62'`) and NF3e (`'66'`) spend position 36 of the key on `nSiteAutoriz`, the site of the authorizer that received the document, so for those two models the result also carries `authorizationSite` and `code` is 7 digits instead of 8. + +```javascript +import { getNfeKeyInfo } from '@brazilian-utils/brazilian-utils'; + +getNfeKeyInfo('35170458716523000119550010000000121000123458'); +// { stateCode: 'SP', year: 2017, month: 4, taxId: '58716523000119', model: '55', // series: 1, number: 12, emissionType: 1, code: '00012345', checkDigit: 8 } -parseNfeKey('invalid'); // null +getNfeKeyInfo('35170458716523000119620010000000121000123450'); +// { stateCode: 'SP', year: 2017, month: 4, taxId: '58716523000119', model: '62', +// series: 1, number: 12, emissionType: 1, code: '0012345', checkDigit: 0, authorizationSite: 0 } + +getNfeKeyInfo('invalid'); // null ``` ### isValidEmail -Check if email is valid. +Check if email is valid. The accepted set is a practical subset of the WHATWG HTML [valid e-mail address](https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) definition, not of [RFC 5322](https://www.rfc-editor.org/rfc/rfc5322). The local part is limited to letters, digits and `_'+-.`, and may not start with a dot, end with a dot or an apostrophe, or contain two dots in a row. The domain must carry at least one dot, and each dotted label follows the WHATWG production `[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?`, so a label may neither start nor end with a hyphen nor exceed 63 characters; the final label is alphabetic and 2 to 63 letters long, so `user@example.c1` is rejected. Quoted local parts (`"john doe"@example.com`) and address literals (`john@[127.0.0.1]`) are rejected. ```javascript import { isValidEmail } from '@brazilian-utils/brazilian-utils'; @@ -556,12 +605,14 @@ isValidEmail('john.doe@hotmail.com'); // true ### isValidPhone -Check if phone number (mobile or landline) is valid. A Brazilian country code (`+55`, `0055` or a bare `55`) is accepted and removed before validation, under the rule documented in `parsePhone`. `options.accept` (typed as `PhoneType[]`, part of `IsValidPhoneOptions`) picks which kinds of number count as valid and defaults to `['mobile', 'landline']`; add `'service'` to also accept the non-geographic numbers recognized by `isValidServicePhone`, or pass `[]` to accept none. +Check if phone number (mobile or landline) is valid. A Brazilian country code (`+55`, `0055` or a bare `55`) is accepted and removed before validation, under the rule documented in `parsePhone`. `options.accept` (typed as `PhoneType[]`, part of `IsValidPhoneOptions`) picks which kinds of number count as valid and defaults to `['mobile', 'landline']`; add `'service'` to also accept the non-geographic numbers recognized by `isValidServicePhone`, or pass `[]` to accept none. `options.version` (typed as `PhoneVersion`, part of the same type) is forwarded to `isValidMobilePhone` and picks which mobile numbering rule is enforced: `1` (default) the legacy format, whose first number digit may be 6, 7, 8 or 9, and `2` the current one of Resolução Anatel 749/2022, art. 12, I, "a", which accepts 7, 8 or 9 and rejects the `700` prefix. It only affects mobile numbers; landline and service numbers are unaffected. ```javascript import { isValidPhone } from '@brazilian-utils/brazilian-utils'; isValidPhone('11900000000'); // true +isValidPhone('11712345678', { version: 2 }); // true (7, 8 and 9 are all SMP) +isValidPhone('11700123456', { version: 2 }); // false (the 700 series is satellite) isValidPhone('+55 11 98765-4321'); // true (country code accepted) isValidPhone('08001234567'); // false (service numbers rejected by default) isValidPhone('08001234567', { accept: ['service'] }); // true @@ -570,7 +621,7 @@ isValidPhone('11900000000', { accept: [] }); // false ### formatPhone -Format phone number according to Brazilian patterns. `options.mask` (typed as `PhoneMask`) accepts `"sn"` (default, subscriber number only, 9 digits, no DDD), `"nanp"` (DDD + subscriber number, 11 digits), `"e164"` (`"+5511987654321"`), `"international"` (`"+55 11 98765-4321"`, the way a Brazilian number is printed for foreign callers), `"service"` (`"0800 123 4567"` or `"4004-1234"`, the conventional groupings for service numbers) or `"auto"`. `"auto"` picks `"international"` when `value` carries a Brazilian country code (`+55`, `0055` or a bare `55` followed by 10 or 11 digits), `"service"` when `value` is a service number, and otherwise falls back to the digit count: `"nanp"` when `value` has more digits than a bare subscriber number, `"sn"` when it does not. `"e164"` and `"international"` drop the country code from `value` first, under the rule documented in `parsePhone`, and fall back to the `"service"` presentation for a service number, since those have no E.164 form. If `value` includes a DDD, pass `{ mask: 'auto' }` (or `'nanp'`) explicitly, since the default `"sn"` mask assumes no DDD and silently truncates one if present. +Format phone number according to Brazilian patterns. `options.mask` (typed as `PhoneMask`) accepts `"sn"` (default, subscriber number only, 9 digits, no DDD), `"nanp"` (DDD + subscriber number, `"(00) 00000-0000"` for the 11 digits of a mobile and `"(00) 0000-0000"` for the 10 digits of a landline, any other length keeping the 11 digit grouping), `"e164"` (`"+5511987654321"`), `"international"` (`"+55 11 98765-4321"`, the way a Brazilian number is printed for foreign callers), `"service"` (`"0800 123 4567"` or `"4004-1234"`, the conventional groupings for service numbers) or `"auto"`. `"auto"` picks `"international"` when `value` carries a Brazilian country code (`+55`, `0055` or a bare `55` followed by 10 or 11 digits), `"service"` when `value` is a service number, and otherwise falls back to the digit count: `"nanp"` when `value` has more digits than a bare subscriber number, `"sn"` when it does not. `"e164"` and `"international"` drop the country code from `value` first, under the rule documented in `parsePhone`, and fall back to the `"service"` presentation for a service number, since those have no E.164 form. If `value` includes a DDD, pass `{ mask: 'auto' }` (or `'nanp'`) explicitly, since the default `"sn"` mask assumes no DDD and silently truncates one if present. A `mask` outside the union falls back to the default `"sn"` instead of throwing. ```javascript import { formatPhone } from '@brazilian-utils/brazilian-utils'; @@ -578,6 +629,8 @@ import { formatPhone } from '@brazilian-utils/brazilian-utils'; formatPhone('987654321'); // 98765-4321 (default "sn", no DDD) formatPhone('11900000000', { mask: 'nanp' }); // (11) 90000-0000 formatPhone('11900000000', { mask: 'auto' }); // (11) 90000-0000 +formatPhone('1130000000', { mask: 'nanp' }); // (11) 3000-0000 (10 digit landline) +formatPhone('1130000000', { mask: 'auto' }); // (11) 3000-0000 (10 digit landline) formatPhone('11987654321', { mask: 'e164' }); // +5511987654321 formatPhone('+5511987654321', { mask: 'international' }); // +55 11 98765-4321 formatPhone('08001234567', { mask: 'service' }); // 0800 123 4567 @@ -601,14 +654,16 @@ parsePhone('55987654321'); // 55987654321 (area code 55, not mistaken for the +5 ### isValidMobilePhone -Check if mobile phone number is valid. `options.version` (typed as `PhoneVersion`) controls which mobile numbering rule is enforced: `1` (default) is the pre-Resolução Anatel 749/2022 format, kept for 2.3.0 compatibility, whose first number digit (after the DDD) may be 6, 7, 8 or 9; `2` enforces only 9, a stricter subset of the resolution's art. 12 I (Serviço Móvel Pessoal). +Check if mobile phone number is valid. `options.version` (typed as `PhoneVersion`) controls which mobile numbering rule is enforced: `1` (default) is the pre-Resolução Anatel 749/2022 format, kept for 2.3.0 compatibility, whose first number digit (after the DDD) may be 6, 7, 8 or 9; `2` enforces the resolution's art. 12, I, "a", which places 7, 8 and 9 in the Serviço Móvel Pessoal (SMP), so a leading 6 is Reserva Técnica and is rejected. Version `2` also carves out the `700` prefix, which art. 12, II reserves for the Serviço Móvel Global por Satélite rather than SMP, so `isValidMobilePhone('11700123456', { version: 2 })` is `false`; version `1` does not carve it out and accepts it. ```javascript import { isValidMobilePhone } from '@brazilian-utils/brazilian-utils'; isValidMobilePhone('11900000000'); // true isValidMobilePhone('11712345678', { version: 1 }); // true (legacy format) -isValidMobilePhone('11712345678', { version: 2 }); // false (v2 requires 9 as the first digit) +isValidMobilePhone('11712345678', { version: 2 }); // true (7 is SMP as well) +isValidMobilePhone('11612345678', { version: 2 }); // false (6 is Reserva Técnica) +isValidMobilePhone('11700123456', { version: 2 }); // false (the 700 series is satellite) ``` ### isValidLandlinePhone @@ -623,7 +678,7 @@ isValidLandlinePhone('1130000000'); // true ### isValidServicePhone -Check if a phone number is a valid Brazilian service number, dialed without a DDD: the Códigos Não Geográficos `0300`, `0303`, `0500`, `0800` and `0900` (11 digits total), the abbreviated `300X`/`400X` numbers (8 digits), and the 3-digit Códigos de Acesso a Serviços de Utilidade Pública that Anatel has designated (e.g. `190`, `192`; `112` and `911` are accepted too, as mobile-only aliases of `190` that Anatel lists alongside the other 3-digit codes). Only the structure is checked, the number does not have to be assigned to anyone. +Check if a phone number is a valid Brazilian service number, dialed without a DDD: the Códigos Não Geográficos `0300`, `0303`, `0500`, `0800` and `0900` (11 digits total, so the shorter, extinct `0800` + 6 digit form is rejected), the abbreviated `300X`/`400X` numbers (8 digits), and the 3-digit Códigos de Acesso a Serviços de Utilidade Pública that Anatel has designated (e.g. `190`, `192`), whose consolidated table is the Anexo of [Ato Anatel nº 43.151/2004](https://informacoes.anatel.gov.br/legislacao/atos-de-numeracao/2004/1648-ato-43151). `112` and `911` are rejected: Anatel designates neither, and `911` is not even inside the `1N₂N₁` range art. 13 of [Resolução nº 749/2022](https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749) destines to public utility services, so the way handsets route them is a GSM convention rather than a numbering designation. Only the structure is checked: the number does not have to be assigned to anyone, and the `0500` rule that encodes a donation amount in the last two digits is not enforced. Anatel withdrew the 4-digit codes instead of allocating them (art. 43 I of [Resolução nº 86/1998](https://informacoes.anatel.gov.br/legislacao/resolucoes/1998/336-resolucao-86) and art. 2º II of the Ato above both ordered them released), so only the conventional `300X` and `400X` roots are recognised: other "Número Único" carrier prefixes in market use, such as `4020` and `4062`, are out of scope and are rejected. ```javascript import { isValidServicePhone } from '@brazilian-utils/brazilian-utils'; @@ -638,19 +693,19 @@ isValidServicePhone('11987654321'); // false (geographic number) Get the state (and its region) a Brazilian DDD (area code) belongs to, out of the 67 DDDs in use under the Anatel Plano Geral de Numeração. Accepts a string or a non-negative integer number, stripping any non-digit characters before matching. Exports the `AreaCodeInfo` type. -`stateCode` is always a single state: the one that holds all but a handful of the DDD's municipalities. Four DDDs straddle a state border, and for those `stateCodes` lists the other states too. DDD 61 is the widest of them, serving the Distrito Federal and the twelve Goiás municipalities of the Entorno do Distrito Federal (Águas Lindas de Goiás, Cabeceiras, Cidade Ocidental, Cristalina, Formosa, Luziânia, Novo Gama, Padre Bernardo, Planaltina, Santo Antônio do Descoberto, Valparaíso de Goiás and Vila Boa). The other three are 42, shared by Paraná and Porto União (SC), 47, shared by Santa Catarina and Rio Negro (PR), and 49, shared by Santa Catarina and Barracão (PR). +`stateCode` is always a single state: the one the DDD is seated in, the state of the city the code was allocated around, which is not necessarily the state holding most of its municipalities. Four DDDs straddle a state border, and for those `stateCodes` lists the other states too. DDD 61 is the widest of them, serving the Distrito Federal and the twelve Goiás municipalities of the Entorno do Distrito Federal (Águas Lindas de Goiás, Cabeceiras, Cidade Ocidental, Cristalina, Formosa, Luziânia, Novo Gama, Padre Bernardo, Planaltina, Santo Antônio do Descoberto, Valparaíso de Goiás and Vila Boa), so its `stateCode` is `'DF'` even though the Distrito Federal holds only one of its thirteen municipalities, Brasília. The other three are 42, shared by Paraná and Porto União (SC), 47, shared by Santa Catarina and Rio Negro (PR), and 49, shared by Santa Catarina and Barracão (PR), and there the seat does hold every municipality but the one named. ```javascript import { getAreaCodeInfo } from '@brazilian-utils/brazilian-utils'; getAreaCodeInfo('11'); -// { areaCode: 11, stateCode: 'SP', stateName: 'São Paulo', region: 'Sudeste', stateCodes: ['SP'] } +// { areaCode: 11, stateCode: 'SP', stateName: 'São Paulo', regionCode: 'SE', regionName: 'Sudeste', stateCodes: ['SP'] } getAreaCodeInfo(21); -// { areaCode: 21, stateCode: 'RJ', stateName: 'Rio de Janeiro', region: 'Sudeste', stateCodes: ['RJ'] } +// { areaCode: 21, stateCode: 'RJ', stateName: 'Rio de Janeiro', regionCode: 'SE', regionName: 'Sudeste', stateCodes: ['RJ'] } getAreaCodeInfo('61'); -// { areaCode: 61, stateCode: 'DF', stateName: 'Distrito Federal', region: 'Centro-Oeste', stateCodes: ['DF', 'GO'] } +// { areaCode: 61, stateCode: 'DF', stateName: 'Distrito Federal', regionCode: 'CO', regionName: 'Centro-Oeste', stateCodes: ['DF', 'GO'] } getAreaCodeInfo('00'); // null getAreaCodeInfo(-11); // null @@ -691,14 +746,27 @@ isValidLicensePlate('ABC1234EXTRA'); // false (too many characters) ### isValidRenavam -Check if RENAVAM (Registro Nacional de Veículos Automotores) is valid. Supports both the old format (9 digits) and the new format (11 digits). +Check if RENAVAM (Registro Nacional de Veículos Automotores) is valid. Supports both the old format (9 digits) and the new format (11 digits). Any spaces, dots and hyphens around/between the digits are ignored, but any other character, a letter in particular, makes the value invalid. A registration whose digits are all the same is rejected as well. ```javascript import { isValidRenavam } from '@brazilian-utils/brazilian-utils'; isValidRenavam('639884962'); // true (9 digits, old format) isValidRenavam('00639884962'); // true (11 digits, new format) +isValidRenavam('0063988.4962'); // true (dots and hyphens are ignored) isValidRenavam('12345678901'); // false (invalid checksum) +isValidRenavam('00000000000'); // false (repeated digits) +isValidRenavam('ab00639884962'); // false (letters are rejected) +``` + +### generateRenavam + +Generate a valid random RENAVAM: the 11 digit form, ten base digits plus the check digit. A base whose digits are all the same is drawn again, since `isValidRenavam` rejects those. Uses `Math.random()` internally, so it is not cryptographically secure. + +```javascript +import { generateRenavam } from '@brazilian-utils/brazilian-utils'; + +generateRenavam(); // '12345678900' ``` ### isValidPis @@ -713,7 +781,7 @@ isValidPis('12056412547'); // false ### formatPis -Format PIS number. +Format PIS number. `options.pad` (part of `FormatPisOptions`) left-pads the value with zeros to the full 11 digits before masking (default `false`). ```javascript import { formatPis } from '@brazilian-utils/brazilian-utils'; @@ -734,12 +802,13 @@ parsePis('123.45678.90-1'); // 12345678901 ### formatCep -Format CEP ([brazilian postal code](https://en.wikipedia.org/wiki/C%C3%B3digo_de_Endere%C3%A7amento_Postal)). +Format CEP ([brazilian postal code](https://en.wikipedia.org/wiki/C%C3%B3digo_de_Endere%C3%A7amento_Postal)). `options.pad` (part of `FormatCepOptions`) left-pads the value with zeros to the full 8 digits before masking (default `false`); a CEP that starts with `0` given as a number loses that zero, so pass it as a string or use `pad`. ```javascript import { formatCep } from '@brazilian-utils/brazilian-utils'; formatCep('92500000'); // 92500-000 +formatCep('9250000', { pad: true }); // 09250-000 ``` ### parseCep @@ -754,7 +823,7 @@ parseCep('92500-000'); // 92500000 ### getAddressInfoByCep -Fetch address information for a given CEP using multiple providers. Defaults to `['viacep', 'brasilapi']`. The `'widenet'` provider is deprecated (its endpoint no longer responds) and excluded from the default list, but it can still be requested explicitly via `options.providers` (typed as `CepProvider[]`). The resolved address is typed as `AddressInfo`. +Fetch address information for a given CEP using multiple providers. Defaults to `['viacep', 'brasilapi']`. The `'widenet'` provider is deprecated (its endpoint no longer responds) and excluded from the default list, but it can still be requested explicitly via `options.providers` (typed as `CepProvider[]`). The resolved address is typed as `AddressInfo`. A transient network failure is retried twice per provider, with a 250 ms linear backoff (250 ms, then 500 ms), so a provider that keeps failing is tried up to 3 times and adds about 750 ms before its own failure lands; an HTTP error status or a non-retryable failure is not retried. The providers are started together and raced with `Promise.any`, not queried one after the other, so those retries delay nothing for the other providers, only the moment an all-failed rejection can surface. An `options.providers` that names no known provider rejects with `GetAddressInfoByCepValidationError` ("Nenhum provedor válido especificado"): an empty array, an array of unknown names, and a value that is not an array at all, `null` included. With `providers: ['brasilapi']`, a CEP BrasilAPI does not know rejects with `GetAddressInfoByCepNotFoundError`, since BrasilAPI signals a miss with HTTP 404; any other error status is still a `GetAddressInfoByCepServiceError`. All three extend `GetAddressInfoByCepError`, the base class of every error this util rejects with, so a single `catch` on it covers all of them. ```javascript import { getAddressInfoByCep } from '@brazilian-utils/brazilian-utils'; @@ -764,32 +833,37 @@ const address = await getAddressInfoByCep('01310100'); // { cep: '01310100', state: 'SP', city: 'São Paulo', neighborhood: 'Bela Vista', street: 'Avenida Paulista' } // Using specific providers -const address = await getAddressInfoByCep('01310-100', { +const addressFromProviders = await getAddressInfoByCep('01310-100', { providers: ['viacep', 'brasilapi'] }); // Using number input (will be padded automatically) -const address = await getAddressInfoByCep(1310100); +const addressFromNumber = await getAddressInfoByCep(1310100); ``` ### isValidProcessoJuridico -Validate the processo jurídico number according to [CNJ's definition](https://atos.cnj.jus.br/atos/detalhar/119). +Validate the processo jurídico number according to [CNJ's definition](https://atos.cnj.jus.br/atos/detalhar/119): the `NNNNNNN-DD.AAAA.J.TR.OOOO` layout, the `DD` check digits and the `J`/`TR` pair, which must identify an existing órgão and tribunal from the closed lists defined by Resolução CNJ nº 65/2008, so a number carrying a correct check digit but a court that does not exist is rejected. The closed lists come from art. 1º, § 4º and § 5º of the resolution, § 5º, III in the wording Resolução CNJ nº 477/2022 gave it to seat the TRF da 6ª Região. The unidade de origem (`OOOO`) is only read as four digits, since art. 1º, § 6º leaves its codification to each tribunal and publishes no central list. The CNJ mask separators (whitespace, `.` and `-`) are accepted between the fields, and whitespace around the value is ignored, but any other character, a letter in particular, makes the value invalid. ```javascript import { isValidProcessoJuridico } from '@brazilian-utils/brazilian-utils'; isValidProcessoJuridico('00020802520125150049'); // true +isValidProcessoJuridico('0002080-25.2012.5.15.0049'); // true (CNJ mask) +isValidProcessoJuridico('0000100-68.2008.4.06.0000'); // true (TRF da 6ª Região) +isValidProcessoJuridico('0000100-23.2008.8.28.0000'); // false (no 28th Tribunal de Justiça) +isValidProcessoJuridico('ab00020802520125150049'); // false (letters are rejected) ``` ### formatProcessoJuridico -Format the processo jurídico number according to [CNJ's definition](https://atos.cnj.jus.br/atos/detalhar/119) (mask `NNNNNNN-DD.AAAA.J.TR.OOOO`). +Format the processo jurídico number according to [CNJ's definition](https://atos.cnj.jus.br/atos/detalhar/119) (mask `NNNNNNN-DD.AAAA.J.TR.OOOO`). `options.pad` (part of `FormatProcessoJuridicoOptions`) left-pads the value with zeros to the full 20 digits before masking (default `false`). ```javascript import { formatProcessoJuridico } from '@brazilian-utils/brazilian-utils'; formatProcessoJuridico('00020802520125150049'); // 0002080-25.2012.5.15.0049 +formatProcessoJuridico('20802520125150049', { pad: true }); // 0002080-25.2012.5.15.0049 ``` ### parseProcessoJuridico @@ -804,7 +878,7 @@ parseProcessoJuridico('0002080-25.2012.5.15.0049'); // 00020802520125150049 ### isValidIe -Check if inscrição estadual (state registration) is valid. The state code is case-insensitive. Notable per-state rules: GO accepts prefixes `10`, `11` and `15`; PA accepts `15` and `75`-`79`; MS accepts `28` and `50`; SP has a produtor rural pattern `P0MMMSSSSD000`; TO uses 11-digit type codes (`01`, `02`, `03`, `99`). +Check if inscrição estadual (state registration) is valid. The state code is case-insensitive. Notable per-state rules: GO accepts prefixes `10`, `11` and `15`; PA accepts `15` and `75`-`79`; MS accepts `28` and `50`; SP has a produtor rural pattern `P0MMMSSSSD000`; TO uses 11-digit type codes (`01`, `02`, `03`, `99`). TO also accepts a 9-digit form, applying the same modulus 11 rule to the first eight digits; the SINTEGRA page documents only the 11-digit one, so that shape is 2.3.0 behaviour kept for compatibility rather than a published rule. An all-zero registration is accepted wherever the published formula yields a check digit of 0 for it (AM, BA with 8 or 9 digits, CE, ES, MG, MT, PB, PE, PI, PR, RJ, RS, SC, SE, SP and TO with 9 digits), unlike `isValidCpf` and `isValidCnpj`, which reject repeated digits. AM is on that list through the second branch of its published formula only: the page's first branch, `Se Soma < 11 Então Dígito = 11 - Soma`, gives 11 for an all-zero registration, while the `resto <= 1 ⇒ 0` branch, the one implemented here, gives 0. ```javascript import { isValidIe } from '@brazilian-utils/brazilian-utils'; @@ -821,11 +895,11 @@ Banks validated by their published check digit algorithm: | Bank | Code | Agency | Account | Notes | | --- | --- | --- | --- | --- | -| Banco do Brasil | `001` | 4-5 digits | 8-10 digits | mod11 with weights 9..2; `digit` may be `"X"` | +| Banco do Brasil | `001` | 4-5 digits | 8-10 digits | mod11 with weights 2..9 cycling from the right; `digit` may be `"X"` | | Santander | `033` | 4 digits | 8 digits | weights `9,7,3,1,0,0,9,7,1,3,1,9,7,3` over agency + `"00"` + account, tens discarded | | Banrisul | `041` | 4 digits | 9 digits | weights `3,2,4,7,6,5,4,3,2`; remainder 0 gives `0` and remainder 1 gives `6`; `account` is tipo (2 digits) + conta (7 digits) | | Caixa Econômica Federal | `104` | 4 digits | 11 digits | mod11 over agency + account; `account` is operação (3 digits) + conta (8 digits) | -| Bradesco | `237` | 4 digits | 7 digits | mod11 with weights 2..7; `digit` may be `"P"` (often rendered as `"0"`) | +| Bradesco | `237` | 4 digits | 7 digits | mod11 with weights 2..7 cycling from the right; remainder 0 gives `0` and remainder 1 gives `"P"` | | Nubank | `260` | 4 digits | 5-13 digits | Verhoeff check digit over the account, leading zeros dropped | | Itaú Unibanco | `341` | 4 digits | 5 digits | mod10 over agency + account | | HSBC / Kirton Bank | `399` | 4 digits | 6 digits | weights `8,9,2,3,4,5,6,7,8,9` over agency + account; remainder 10 gives `0` | @@ -835,17 +909,15 @@ Banks validated by structure only, because they publish no check digit rule. The | Bank | Code | | Bank | Code | | --- | --- | --- | --- | --- | -| Inter | `077` | | PicPay | `380` | -| Ailos | `085` | | Cora | `403` | -| XP | `102` | | Pan | `623` | -| Unicred | `136` | | BV | `655` | -| Stone | `197` | | Daycoval | `707` | -| BTG Pactual | `208` | | Modal | `746` | -| Original | `212` | | Sicredi | `748` | -| PagBank | `290` | | Sicoob | `756` | -| BMG | `318` | | | | -| Mercado Pago | `323` | | | | -| C6 | `336` | | | | +| Inter | `077` | | Mercado Pago | `323` | +| Ailos | `085` | | C6 | `336` | +| XP | `102` | | PicPay | `380` | +| Unicred | `136` | | Cora | `403` | +| Stone | `197` | | Pan | `623` | +| BTG Pactual | `208` | | BV | `655` | +| Original | `212` | | Daycoval | `707` | +| PagBank | `290` | | Sicredi | `748` | +| BMG | `318` | | Sicoob | `756` | When `digit` has 2 characters, the generic fallback chains mod10 followed by mod11 over the account, the same way CPF/CNPJ check digits are chained. @@ -948,7 +1020,7 @@ getBankByCode('999'); // null ### getBankByIspb -Look a Brazilian bank up by its ISPB (Identificador do Sistema de Pagamentos Brasileiro), the 8 digit code published by Banco Central do Brasil in the [STR participants list](https://www.bcb.gov.br/content/estabilidadefinanceira/str1/ParticipantesSTR.csv). Every SPB participant has an ISPB, but this dataset only carries the institutions that also have a COMPE code, so an ISPB whose institution has no COMPE code of its own returns `null`. Accepts both `string` and `number` input, with or without leading zeros. Returns a fresh copy (typed as `Bank`) of the matching bank, or `null` when no bank has that ISPB. +Look a Brazilian bank up by its ISPB (Identificador do Sistema de Pagamentos Brasileiro), the 8 digit code published by Banco Central do Brasil in the [STR participants list](https://www.bcb.gov.br/content/estabilidadefinanceira/str1/ParticipantesSTR.csv). Every SPB participant has an ISPB, but this dataset only carries the institutions that also have a COMPE code, so an ISPB whose institution has no COMPE code of its own returns `null`. Accepts both `string` and `number` input, with or without leading zeros, so `getBankByIspb(0)` finds the same bank as `getBankByIspb('00000000')`. The dataset is generated from that CSV, falling back to [BrasilAPI](https://brasilapi.com.br/api/banks/v1) when the Bacen request fails. Returns a fresh copy (typed as `Bank`) of the matching bank, or `null` when no bank has that ISPB. ```javascript import { getBankByIspb } from '@brazilian-utils/brazilian-utils'; @@ -960,21 +1032,22 @@ getBankByIspb('99999999'); // null ### isValidIban -Check if a Brazilian IBAN (International Bank Account Number) is valid, per Bacen's [Diretrizes de Implementação do IBAN no Brasil](https://www.bcb.gov.br/content/estabilidadefinanceira/Documents/sistema_pagamentos_brasileiro/IBAN-Guidelines_%20port.pdf) (Circular BCB nº 3.625/2013): `BR` + 2 ISO 7064 MOD 97-10 check digits + 8 digit ISPB + 5 digit branch + 10 digit account + 1 letter account type (any letter, usually `C` for conta corrente or `P` for conta poupança) + 1 alphanumeric owner indicator, 29 characters total. Only Brazilian IBANs (country code `BR`) are recognized; any other country returns `false`, since this package does not carry the field layout of the other 90+ ISO 13616 countries. Accepts the usual grouping spaces and is case-insensitive. The value has to be written in the ISO 13616 print format: letters and digits in groups separated by a single space, with optional surrounding whitespace. Any other character makes the value something other than an IBAN, so it is rejected instead of being stripped. +Check if a Brazilian IBAN (International Bank Account Number) is valid, per Bacen's [Diretrizes de Implementação do IBAN no Brasil](https://www.bcb.gov.br/content/estabilidadefinanceira/Documents/sistema_pagamentos_brasileiro/IBAN-Guidelines_%20port.pdf) (Circular BCB nº 3.625/2013): `BR` + 2 ISO 7064 MOD 97-10 check digits + 8 digit ISPB + 5 digit branch + 10 digit account + 1 letter account type (any letter, usually `C` for conta corrente or `P` for conta poupança) + 1 owner indicator (`1` for the first or only holder up to `9` for the ninth, then `A` to `Z` from the tenth, so `0` is rejected), 29 characters total. Only Brazilian IBANs (country code `BR`) are recognized; any other country returns `false`, since this package does not carry the field layout of the other 90+ ISO 13616 countries. Is case-insensitive and accepts both forms an IBAN is written in: compact (`'BR1500000000000010932840814P2'`) or in the ISO 13616 print format, letters and digits in groups of 4 (the last one shorter), with optional surrounding whitespace either way. The groups may be split by whitespace, `.`, `-` or `/`, the interchangeable mask characters `isValidCpf` and `isValidCnpj` accept. Only a separator away from a group boundary, a run of separators (ISO 13616 prints a single one) or a character outside letters and digits makes the value something other than an IBAN, so it is rejected instead of being stripped. ```javascript import { isValidIban } from '@brazilian-utils/brazilian-utils'; isValidIban('BR1500000000000010932840814P2'); // true isValidIban('BR15 0000 0000 0000 1093 2840 814P 2'); // true (grouping spaces) +isValidIban('BR15-0000-0000-0000-1093-2840-814P-2'); // true (any of the mask characters) isValidIban('BR1500000000000010932840814P3'); // false (bad check digits) -isValidIban('BR1500000000000010932840814P-2'); // false (hyphens are not part of an IBAN) +isValidIban('BR15 000 00000 0000 1093 2840 814P 2'); // false (a separator inside a group) isValidIban('DE89370400440532013000'); // false (non Brazilian IBAN) ``` ### formatIban -Format a Brazilian IBAN by grouping it in blocks of 4 characters, the ISO 13616 "print" presentation used on statements and bank forms. Does not validate the check digits or the field layout; formats whatever is given, up to the 29 character length of a Brazilian IBAN, as far as it goes, so the function can also be used as an input mask. Use `isValidIban` to check validity. The value still has to be written in the ISO 13616 print format (letters and digits in groups separated by a single space, with optional surrounding whitespace); any other character returns an empty string instead of being quietly dropped. +Format an IBAN in the ISO 13616 print grouping, blocks of 4 characters, the presentation used on statements and bank forms. Does not validate the check digits or the field layout; formats whatever is given, up to the 29 character length of a Brazilian IBAN, as far as it goes, so the function can also be used as an input mask, and an IBAN of another country is grouped the same way up to that length. Use `isValidIban` to check validity. The value may be compact (`'BR1500000000000010932840814P2'`), already in the ISO 13616 print format or a partial value still being typed (`'BR15'`); like every formatter of this package, it is read for its letters and digits and grouped as far as they go, any other character (a hyphen, a dot, extra whitespace) is dropped and the letters are uppercased. Only a value that is not a string gives an empty string. ```javascript import { formatIban } from '@brazilian-utils/brazilian-utils'; @@ -982,17 +1055,28 @@ import { formatIban } from '@brazilian-utils/brazilian-utils'; formatIban('BR1500000000000010932840814P2'); // 'BR15 0000 0000 0000 1093 2840 814P 2' formatIban('br1500000000000010932840814p2'); // 'BR15 0000 0000 0000 1093 2840 814P 2' formatIban('BR15'); // 'BR15' -formatIban('BR1500000000000010932840814P-2'); // '' (hyphens are not part of an IBAN) +formatIban('BR15 0000-0000.0000/1093 2840 814P-2'); // 'BR15 0000 0000 0000 1093 2840 814P 2' (only letters and digits are read) ``` ### parseIban -Parses a Brazilian IBAN into its fields: 2 (country code, always `BR`) + 2 (ISO 7064 MOD 97-10 check digits) + 8 (ISPB) + 5 (branch) + 10 (account) + 1 (account type, any letter, usually `C` for conta corrente or `P` for conta poupança) + 1 (owner indicator). Accepts the same input forms as `isValidIban` (grouping spaces, lowercase) and returns `null` whenever `isValidIban` would return `false`, including a value carrying any character other than letters, digits and the grouping spaces of the print format. The result is typed as `Iban`, whose `accountType` is a `string`. +Remove IBAN formatting, keep the letters and digits, uppercase the result, and cap it to the 29 characters of a Brazilian IBAN. An IBAN carries letters as well as digits, so the value is read the way `parsePassport` reads a passport number; use `isValidIban` to check the check digits and `getIbanInfo` to read the fields. ```javascript import { parseIban } from '@brazilian-utils/brazilian-utils'; -parseIban('BR1500000000000010932840814P2'); +parseIban('BR15 0000 0000 0000 1093 2840 814P 2'); // 'BR1500000000000010932840814P2' +parseIban('br15-0000.0000/0000 1093 2840 814p-2'); // 'BR1500000000000010932840814P2' +``` + +### getIbanInfo + +Parses a Brazilian IBAN into its fields: 2 (country code, always `BR`) + 2 (ISO 7064 MOD 97-10 check digits) + 8 (ISPB) + 5 (branch) + 10 (account) + 1 (account type, any letter, usually `C` for conta corrente or `P` for conta poupança) + 1 (owner indicator, `1` to `9` then `A` to `Z`). Only Brazilian IBANs are supported: the field layout of the other ISO 13616 countries is out of scope, so a well-formed non `BR` IBAN also returns `null`. Accepts the same input forms as `isValidIban`, compact or in the ISO 13616 print format (groups of 4 split by a single whitespace, `.`, `-` or `/`), in either case with optional surrounding whitespace and in any case, and returns `null` whenever `isValidIban` would return `false`, including a value carrying a separator away from a group boundary, a run of separators or any character other than letters and digits. The result is typed as `IbanInfo`, whose `accountType` is a `string`. + +```javascript +import { getIbanInfo } from '@brazilian-utils/brazilian-utils'; + +getIbanInfo('BR1500000000000010932840814P2'); // { // countryCode: 'BR', // checkDigits: '15', @@ -1003,13 +1087,13 @@ parseIban('BR1500000000000010932840814P2'); // owner: '2' // } -parseIban('DE89370400440532013000'); // null (non Brazilian IBAN) -parseIban('BR1500000000000010932840814P-2'); // null (hyphens are not part of an IBAN) +getIbanInfo('DE89370400440532013000'); // null (non Brazilian IBAN) +getIbanInfo('BR15 000 00000 0000 1093 2840 814P 2'); // null (a separator inside a group) ``` ### isValidCreditCard -Check if a payment card number is valid using the Luhn algorithm ([ISO/IEC 7812-1](https://www.iso.org/standard/70484.html)). Accepts the usual mask characters (spaces, hyphens) between digits. Performs no brand detection (Visa, Mastercard, Amex...), issuer range lookup or expiration/CVV checks, only the digit count (12 to 19) and the Luhn check digit. A `number` is only accepted when it is a non-negative safe integer: anything above `Number.MAX_SAFE_INTEGER` (2^53 - 1, 16 digits) has already been rounded to a different number before the function sees it, so pass a longer PAN as a string. +Check if a payment card number is valid using the Luhn algorithm ([ISO/IEC 7812-1](https://www.iso.org/standard/70484.html)). Accepts the usual mask characters (whitespace, `.`, `-` and `/`, the interchangeable set `isValidCpf` and `isValidCnpj` accept) between any two digits and whitespace around the value; any other character makes the value invalid. They are accepted between any two digits rather than at fixed positions because the printed grouping of a PAN changes with the brand (4-4-4-4 for Visa and Mastercard, 4-6-5 for American Express, 4-6-4 for Diners Club), so there is no single layout to pin them to. Performs no brand detection (Visa, Mastercard, Amex...), issuer range lookup or expiration/CVV checks, only the digit count (12 to 19) and the Luhn check digit. A `number` is only accepted when it is a non-negative safe integer: anything above `Number.MAX_SAFE_INTEGER` (2^53 - 1, 16 digits) has already been rounded to a different number before the function sees it, so pass a longer PAN as a string. A value whose digits are all the same (`'0000000000000000'`) is rejected even when it passes the Luhn check, the way every other validator of this package rejects a repeated-digit document (`isValidCpf('00000000000')`, `isValidCns`, `isValidCaepf`, `isValidCei`). ```javascript import { isValidCreditCard } from '@brazilian-utils/brazilian-utils'; @@ -1018,30 +1102,52 @@ isValidCreditCard('4111111111111111'); // true (Visa test number) isValidCreditCard('5555555555554444'); // true (Mastercard test number) isValidCreditCard('378282246310005'); // true (American Express test number) isValidCreditCard('4111 1111 1111 1111'); // true (spaced mask) +isValidCreditCard('4111.1111/1111-1111'); // true (any of the mask characters) isValidCreditCard('4111111111111112'); // false (bad check digit) +isValidCreditCard('0000000000000000'); // false (every digit the same, though the Luhn check passes) +isValidCreditCard('4111a1111b1111c1111'); // false (letters between the digits) isValidCreditCard(4111111111111111111); // false (above 2^53 - 1, pass it as a string) ``` ### capitalize -Transforms the first letter into a capital one of each word ignoring prepositions. Words are separated by whitespace, by `-` and by `/`, so `'MOGI-GUAÇU'` becomes `'Mogi-Guaçu'` and `'SANTANA/RS'` becomes `'Santana/Rs'`. Every run of whitespace (tabs, newlines, repeated spaces) collapses into a single space, and the leading and trailing whitespace is dropped. `options.upperCaseWords` defaults to `[]`, so no acronym is upper-cased unless you list it, and the comparison against both `upperCaseWords` and `lowerCaseWords` is case-insensitive (pt-BR locale). Options are typed as `CapitalizeOptions`. +Transforms the first letter into a capital one of each word, the way a Brazilian name, company name or address is written, with no options needed. Words are separated by whitespace, by `-` and `/`, by the apostrophe (`'d'oeste'` becomes `'d'Oeste'`) and by punctuation that touches a word (`'(empresa)'` becomes `'(Empresa)'`, `'bairro:centro'` becomes `'Bairro:Centro'`), so `'MOGI-GUAÇU'` becomes `'Mogi-Guaçu'`; the separators are kept where they are. Every run of whitespace (tabs, newlines, repeated spaces) collapses into a single space, and the leading and trailing whitespace is dropped. The particles of foreign-origin names (`del`, `della`, `di`, `du`, `van`, `von`, `der`, `den`) stay lower case like the Portuguese prepositions, and so does the elided `d'`, wherever it appears, whenever an apostrophe and a word follow it (`'dias d'ávila'` becomes `'Dias d'Ávila'`); a single letter written right after an apostrophe is the English possessive and stays lower case too (`"bob's"` becomes `"Bob's"`). + +`options.lowerCaseWords` defaults to the Portuguese prepositions, articles and conjunctions that stay in lower case inside a proper name (`de`, `da`, `do`, `e`, ...), and they are only written in lower case when they link two words: one of them that is the first word, that ends the value, or that is followed by punctuation is a designator instead and keeps its capital (`'rua a, 100'` becomes `'Rua A, 100'` and `'condomínio a, quadra d, lote o'` becomes `'Condomínio A, Quadra D, Lote O'`). `options.upperCaseWords` defaults to the company designations and document abbreviations written in upper case in Brazilian usage (`LTDA`, `S.A.`, `S/A`, `S.S.`, `S/S`, `ME`, `EPP`, `MEI`, `EIRELI`, `CIA`, `SCP`, `CNPJ`, `CPF`, `RG`, `CEP`, `UF`) plus the roman numerals that appear in names and addresses (`II` through `XXIII`, except `VI`, which collides with the pt-BR verb form "vi"). `SA` without punctuation is deliberately absent, since it is indistinguishable from the surname "Sá" typed without its accent, while `ME` is also the pronoun "me", so it is only written in upper case in the designation position, as the last word of the value (`'fulano comércio me'` becomes `'Fulano Comércio ME'`) or right before another designation (`'fulano me epp'` becomes `'Fulano ME EPP'`); anywhere else it is an ordinary word (`'diga-me a verdade'` becomes `'Diga-Me a Verdade'`, `'não-me-toque'` becomes `'Não-Me-Toque'`). `S/A` and `S/S` are matched across the slash even though a slash separates words. A two letter word that follows a `/` is upper-cased when it is the code of a Brazilian state (`'porto alegre/rs'` becomes `'Porto Alegre/RS'`); that rule is structural and stays on even when `upperCaseWords` is given, while a state code that does not follow a `/` is left alone. + +Either list given in `options` replaces its default entirely, and the comparison against both is case-insensitive (pt-BR locale). Options are typed as `CapitalizeOptions`. ```javascript import { capitalize } from '@brazilian-utils/brazilian-utils'; -capitalize('josé e maria'); // José e Maria +capitalize('jose da silva'); // Jose da Silva +capitalize('JOSÉ DA SILVA'); // José da Silva +capitalize('empresa ltda'); // Empresa LTDA +capitalize('banco do brasil s.a.'); // Banco do Brasil S.A. +capitalize('casa de carnes s/a'); // Casa de Carnes S/A ("S/A" is matched across the slash) +capitalize('mogi-guaçu'); // Mogi-Guaçu ("-" starts a new word) +capitalize("santa bárbara d'oeste"); // Santa Bárbara d'Oeste ("'" starts a new word, "d" stays lower case) +capitalize("bob's"); // Bob's (a single letter after an apostrophe is the English possessive) +capitalize('rua a, 100'); // Rua A, 100 (a preposition followed by punctuation is a designator) +capitalize('fulano comércio me'); // Fulano Comércio ME ("ME" as the last word is the designation) +capitalize('não-me-toque'); // Não-Me-Toque (anywhere else "me" is an ordinary word) +capitalize('(empresa) ltda'); // (Empresa) LTDA +capitalize('luiz von schmidt'); // Luiz von Schmidt +capitalize('santana/rs'); // Santana/RS ("RS" is a state code right after a "/") +capitalize('porto alegre/rs'); // Porto Alegre/RS +capitalize('santana rs'); // Santana Rs (no "/", so "rs" is just a word) +capitalize('rua xv de novembro'); // Rua XV de Novembro (roman numeral, "de" stays lower case) +capitalize('joão paulo ii'); // João Paulo II +capitalize('de'); // De (a preposition keeps its capital when it is the first word) +capitalize('empresa ltda', { upperCaseWords: [] }); // Empresa Ltda (the list given replaces the default one) capitalize('josé Ama MARIA', { lowerCaseWords: ['ama'] }); // José ama Maria -capitalize('doc inválido', { upperCaseWords: ['DOC'] }); // DOC Inválido -capitalize('MOGI-GUAÇU'); // Mogi-Guaçu ("-" starts a new word) -capitalize('SANTANA/RS', { upperCaseWords: ['RS'] }); // Santana/RS ("/" starts a new word, so "RS" matches) -capitalize('empresa ltda'); // Empresa Ltda (no default acronyms) -capitalize('empresa ltda', { upperCaseWords: ['LTDA'] }); // Empresa LTDA (case-insensitive match) +capitalize('doc inválido', { upperCaseWords: ['DOC'] }); // DOC Inválido (case-insensitive match) capitalize(' josé maria '); // José Maria (every run of whitespace, tabs and newlines included, collapses into one space) ``` ### formatCurrency -Formats an integer or float to a string in the BRL pattern. A `number` is formatted as-is (sign and decimals preserved). A `string` input is read by the same rule as `parseCurrency`, except that a value written without any separator stays in whole units: the last `,` or `.` followed by 1 to 2 digits is the decimal separator, every other `,` or `.` is a thousands separator, and a `-` written before the first digit is preserved. So `'1.234,56'` formats as `1.234,56`, `'-10.5'` as `-10,50` and `'1234'` as `1.234,00`. `precision` is clamped to `0..20` (the range `Intl.NumberFormat` accepts) and defaults to 2. A value that is not a finite number (`NaN`, `Infinity`, `-Infinity`) formats as an empty string. Options are typed as `FormatCurrencyOptions`. +Formats an integer or float to a string in the BRL pattern. A `number` is formatted as-is (sign and decimals preserved). A `string` input is read by the same rule as `parseCurrency`, except that a value written without any separator stays in whole units: the last `,` or `.` followed by 1 to 2 digits (or up to `precision` digits, when that is larger) is the decimal separator, every other `,` or `.` is a thousands separator, and a `-` written before the first digit is preserved. So `'1.234,56'` formats as `1.234,56`, `'-10.5'` as `-10,50` and `'1234'` as `1.234,00`. `precision` is clamped to `0..20` (the package limit, the bound Node 20 still enforces on `Intl.NumberFormat`), defaults to 2, and falls back to 2 when it is not a finite number. A value that is not a finite number (`NaN`, `Infinity`, `-Infinity`) formats as an empty string, and so does a value that cannot be coerced to a number (a symbol, a plain object, a null-prototype object); `null`, arrays and booleans go through `Number()` as in 2.3.0. `options.symbol` prefixes the result with the `R$` currency symbol (default `false`). Options are typed as `FormatCurrencyOptions`. ```javascript import { formatCurrency } from '@brazilian-utils/brazilian-utils'; @@ -1059,7 +1165,7 @@ formatCurrency(Number.NaN); // "" (non finite numbers format as an empty string) ### parseCurrency -Transforms a string to an integer or float format. The last `,` or `.` followed by 1 to 2 digits (or up to `precision` digits, when that is larger) is the decimal separator; every other `,` or `.` is a thousands separator. So `'R$ 1.234,56'` parses to `1234.56`, `'R$ 1.234'` to `1234`, `'1,5'` to `1.5` and `'12.34'` to `12.34`. A value written without any separator keeps the cents convention and is divided by `10 ** precision`, so `'1234'` parses to `12.34`. A `-` written before the first digit is preserved, so `'-R$ 1,00'` parses to `-1`. `precision` (default 2, clamped to `0..20`) controls how many digits are treated as minor units. Options are typed as `ParseCurrencyOptions`. +Transforms a string to an integer or float format. The last `,` or `.` followed by 1 to 2 digits (or up to `precision` digits, when that is larger) is the decimal separator; every other `,` or `.` is a thousands separator. So `'R$ 1.234,56'` parses to `1234.56`, `'R$ 1.234'` to `1234`, `'1,5'` to `1.5` and `'12.34'` to `12.34`. A value written without any separator keeps the cents convention and is divided by `10 ** precision`, so `'1234'` parses to `12.34`. A `-` written before the first digit is preserved, so `'-R$ 1,00'` parses to `-1`. `precision` (default 2, clamped to `0..20`, and falling back to 2 when it is not a finite number) controls how many digits are treated as minor units. Options are typed as `ParseCurrencyOptions`. ```javascript import { parseCurrency } from '@brazilian-utils/brazilian-utils'; @@ -1077,7 +1183,7 @@ parseCurrency(''); // 0 ### convertNumberToWords -Formats an integer as its Brazilian Portuguese cardinal number words ("por extenso"), e.g. `1235` becomes `"mil, duzentos e trinta e cinco"`. Only integers from `-999999999999999` to `999999999999999` (999 trillion in absolute value) are supported; anything outside that range, `NaN` or a non-finite value returns `""`. A non-integer `value` is truncated toward zero before conversion. `options.gender` (part of `ConvertNumberToWordsOptions`) agrees "um/dois" and the hundreds group ("duzentos/duzentas", etc.) with the noun the number qualifies, defaulting to `"masculine"`. `options.case` sets the letter case of the result: `"lower"` (default, unchanged), `"sentence"` (capitalizes only the first letter) or `"upper"` (uppercases everything with the "pt-BR" locale, keeping accents, e.g. "três" -> "TRÊS"). An invalid `gender`/`case` value is ignored and the default is used. +Formats an integer as its Brazilian Portuguese cardinal number words ("por extenso"), e.g. `1235` becomes `"mil duzentos e trinta e cinco"`. Only integers from `-999999999999999` to `999999999999999` (999 trillion in absolute value) are supported; anything outside that range, `NaN` or a non-finite value returns `""`. A non-integer `value` is truncated toward zero before conversion. `options.gender` (part of `ConvertNumberToWordsOptions`) agrees "um/dois" and the hundreds group ("duzentos/duzentas", etc.) with the noun the number qualifies, defaulting to `"masculine"`. An invalid `gender` value is ignored and the default is used. The result is always lowercase; apply any other casing to it yourself. ```javascript import { convertNumberToWords } from '@brazilian-utils/brazilian-utils'; @@ -1087,25 +1193,24 @@ convertNumberToWords(1001); // "mil e um" convertNumberToWords(2000000); // "dois milhões" convertNumberToWords(-42); // "menos quarenta e dois" convertNumberToWords(2, { gender: 'feminine' }); // "duas" -convertNumberToWords(3, { case: 'upper' }); // "TRÊS" +convertNumberToWords(12.9); // "doze" (truncated toward zero) convertNumberToWords(NaN); // "" ``` ### convertCurrencyToWords -Formats a monetary amount in Brazilian Reais as its "por extenso" textual representation, the style used to write out the amount by hand on cheques and contracts, e.g. `1523.45` becomes `"mil, quinhentos e vinte e três reais e quarenta e cinco centavos"`. `value` is truncated (not rounded) to 2 decimal places. The singular noun is used for exactly 1 ("um real", "um centavo") and "de" is inserted before "reais" when the amount is a round million, billion or trillion of reais. An amount that truncates to nothing becomes `"zero reais"` with no "menos" prefix, any other negative amount is prefixed with "menos", and invalid input returns `""`. Above `Number.MAX_SAFE_INTEGER / 100` reais (about 90 trillion) a double cannot carry cents, so the amount is read as a whole number of reais. `options.case` (part of `ConvertCurrencyToWordsOptions`) sets the letter case of the result: `"lower"` (default), `"sentence"` (capitalizes only the first letter) or `"upper"` (uppercases everything, keeping accents). An invalid `case` value is ignored and `"lower"` is used. +Formats a monetary amount in Brazilian Reais as its "por extenso" textual representation, the style used to write out the amount by hand on cheques and contracts, e.g. `1523.45` becomes `"mil quinhentos e vinte e três reais e quarenta e cinco centavos"`. `value` is truncated (not rounded) to 2 decimal places. The singular noun is used for exactly 1 ("um real", "um centavo") and "de" is inserted before "reais" when the amount is a round million, billion or trillion of reais. An amount that truncates to nothing becomes `"zero reais"` with no "menos" prefix, any other negative amount is prefixed with "menos", and invalid input returns `""`. Above `Number.MAX_SAFE_INTEGER / 100` reais (about 90 trillion) a double cannot carry cents, so the amount is read as a whole number of reais. It takes no options: the result is always lowercase; apply any other casing to it yourself. ```javascript import { convertCurrencyToWords } from '@brazilian-utils/brazilian-utils'; -convertCurrencyToWords(1523.45); // "mil, quinhentos e vinte e três reais e quarenta e cinco centavos" +convertCurrencyToWords(1523.45); // "mil quinhentos e vinte e três reais e quarenta e cinco centavos" convertCurrencyToWords(1); // "um real" convertCurrencyToWords(0.01); // "um centavo" convertCurrencyToWords(1000000); // "um milhão de reais" convertCurrencyToWords(0); // "zero reais" convertCurrencyToWords(-5.5); // "menos cinco reais e cinquenta centavos" convertCurrencyToWords(-0.001); // "zero reais" (truncates to nothing) -convertCurrencyToWords(1000, { case: 'upper' }); // "MIL REAIS" ``` ### getStates @@ -1149,7 +1254,7 @@ getStates(); ### getStateByIbgeCode -Get the Brazilian state whose 2-digit IBGE code ("cUF", the Código da Unidade da Federação) matches the given value. This is the same 2-digit UF code found in the first field of every DF-e access key (chave de acesso) issued for NF-e, NFC-e, CT-e and MDF-e documents. Accepts a string or a non-negative integer number, stripping any non-digit characters before matching. Exports the `State` type. +Get the Brazilian state whose 2-digit IBGE code ("cUF", the Código da Unidade da Federação) matches the given value. This is the same 2-digit UF code found in the first field of every DF-e access key (chave de acesso) issued for any of the models `isValidNfeKey` covers: NF-e (55), NFC-e (65), CT-e (57), MDF-e (58), CT-e OS (67), GTV-e (64), BP-e (63), NF3e (66) and NFCom (62). Accepts a string or a non-negative integer number, stripping any non-digit characters before matching. Exports the `State` type. ```javascript import { getStateByIbgeCode } from '@brazilian-utils/brazilian-utils'; @@ -1167,7 +1272,7 @@ getStateByIbgeCode(3.5); // null ### getStateCodeByName -Get the two-letter code (sigla) of a Brazilian state given its full name. The match is accent-insensitive, case-insensitive and ignores leading/trailing whitespace, so `'sao paulo'`, `'SÃO PAULO'` and `' São Paulo '` all resolve to `'SP'`. Exports the `StateCode` type. +Get the two-letter code (sigla) of a Brazilian state given its full name. The match is accent-insensitive, case-insensitive and ignores leading/trailing whitespace, so `'sao paulo'`, `'SÃO PAULO'` and `' São Paulo '` all resolve to `'SP'`. Every run of internal whitespace collapses into a single space too, so `'Rio de Janeiro'` resolves to `'RJ'`, while a name written without the space matches nothing (`'saopaulo'` is not `'São Paulo'`). Exports the `StateCode` type. ```javascript import { getStateCodeByName } from '@brazilian-utils/brazilian-utils'; @@ -1207,7 +1312,7 @@ getTimezoneByState('ZZ'); // null ### getCities -Get Brazilian cities. Returns all cities if no state is provided, or cities from a specific state. Each call returns a fresh array, so mutating the result never affects subsequent calls. An unknown state code (or a non-`StateCode` value) returns an empty array instead of throwing. +Get Brazilian cities. **Deprecated:** use `getMunicipalities` instead. Returns all cities if no state is provided, or cities from a specific state. Each call returns a fresh array, so mutating the result never affects subsequent calls. An unknown state code (or a non-`StateCode` value) returns an empty array instead of throwing, except for a falsy one: `getCities(null)` and `getCities('')` are read as "no state given" and return every city, where the stricter `getMunicipalities` returns `[]` for them. The state code is matched exactly, case included: `getCities('sp')` returns `[]` where `getCities('SP')` returns the 645 São Paulo cities. `getCities` and `getMunicipalities` are the only state-taking lookups that are case-sensitive; `getStateNameByCode`, `getTimezoneByState`, `getAreaCodesByState` and `getMunicipality` all fold case. ```javascript import { getCities } from '@brazilian-utils/brazilian-utils'; @@ -1245,11 +1350,22 @@ getCities('SP'); // ] ``` -`getCities` embeds all 5571 IBGE municipality names (~153 KB minified, ~49 KB gzipped) and is one of the few heavy exceptions in this otherwise tree-shakeable package. See [Bundle size](getting-started.md#bundle-size) for how to lazy-load it via `@brazilian-utils/brazilian-utils/get-cities` instead of the root import. +`getCities` embeds all 5571 IBGE municipality names (~154.2 KB minified, ~49.8 KB gzipped) and is one of the few heavy exceptions in this otherwise tree-shakeable package. See [Bundle size](getting-started.md#bundle-size) for how to lazy-load it via `@brazilian-utils/brazilian-utils/get-cities` instead of the root import. ### getHolidays -Get Brazilian holidays for a given year. Returns national holidays and optionally state-specific holidays. Each holiday (typed as `Holiday`) has a `type` field (`HolidayType`: `"national"`, `"state"`, `"optional"` or `"religious"`). "Dia da Consciência Negra" (Nov 20) is a national holiday from 2024 onward (Lei nº 14.759/2023). Before that, MT and RJ still carry their own state-level entry named `"Consciência Negra"` on the same date. Results are memoized per `year`/`stateCode`, but each call still returns a fresh copy. An unknown/invalid `stateCode` is ignored, returning national holidays only. +Get Brazilian holidays for a given year. Returns national holidays and optionally state-specific holidays. Each holiday (typed as `Holiday`) has a `type` field (`HolidayType`: `"national"`, `"state"`, `"optional"` or `"religious"`). "Dia da Consciência Negra" (Nov 20) is a national holiday from 2024 onward (Lei nº 14.759/2023). Before that, several states still carry a state-level entry of their own on the same date: `"Consciência Negra"` in MT and RJ, `"Dia Estadual da Consciência Negra"` in AP and `"Dia da Consciência Negra"` in AM and SP. Results are memoized per `year`/`stateCode`, but each call still returns a fresh copy. An unknown/invalid `stateCode` is ignored, returning national holidays only; the lookup reads own properties only, so `"__proto__"`, `"constructor"` and the like are unknown state codes rather than a crash. Only the years 1900 through 2099 are supported, the range the business day utilities inherit; a year outside it returns `[]`. + +Only one state holiday per UF is a feriado civil under [Lei nº 9.093/1995](https://www.planalto.gov.br/ccivil_03/leis/l9093.htm), art. 1º, II, which authorises "a data magna do Estado fixada em lei estadual" in the singular; the other entries rest on ordinary state laws and are reported because they are observed in practice. Notable per-state rules: + +- **SC** — [Lei SC nº 18.531/2022](http://leis.alesc.sc.gov.br/html/2022/18531_2022_lei.html) moves both state holidays, "Dia do Estado de Santa Catarina" (Aug 11) and "Dia de Santa Catarina de Alexandria" (Nov 25), to the following Sunday whenever they fall Monday to Friday, so Monday Aug 11 2025 is a business day in SC and the holiday lands on Sunday Aug 17. The two dates did not start transferring together. Aug 11 transfers from 2005 on, the year [Lei SC nº 13.408/2005](http://leis.alesc.sc.gov.br/html/2005/13408_2005_lei.html) extended the clause to it (published and in force on Jul 15 2005), and stays on Aug 11 before that. Nov 25 transfers from 1999 on, the year [Lei SC nº 11.213/1999](http://leis.alesc.sc.gov.br/html/1999/11213_1999_lei.html) first introduced the clause (published and in force on Nov 12 1999, thirteen days before that year's Nov 25), with a one-year gap: art. 3º of [Lei SC nº 12.906/2004](http://leis.alesc.sc.gov.br/html/2004/12906_2004_lei.html) revoked that law without restating the clause, so Nov 25 2004 alone stays on the statutory date until Lei SC nº 13.408/2005 reinstated the transfer. So Nov 25 1999 (a Thursday) lands on Sunday Nov 28, Nov 25 2002 (a Monday) on Sunday Dec 1, Nov 25 2004 (a Thursday) stays put, and Nov 25 2005 (a Friday) lands on Sunday Nov 27. +- **DF** — [Lei distrital nº 72/1989](https://www.sinj.df.gov.br/sinj/Norma/18459/Lei_72_27_12_1989.html), art. 1º parágrafo único, declares Corpus Christi a feriado. With `stateCode: 'DF'` the single Corpus Christi entry comes back typed `"state"` instead of `"optional"`; it is replaced, not duplicated. +- **GO** — [Lei GO nº 20.756/2020](https://legisla.casacivil.go.gov.br/pesquisa_legislacao/100979/lei-20756), art. 269, II, lists three feriados estaduais: Jul 26 (Fundação da Cidade de Goiás), Oct 24 (Lançamento da Pedra Fundamental de Goiânia) and Oct 28 (Dia do Servidor Público). +- **AL** — Sep 16 is a feriado estadual from 2024 ([Lei AL nº 9.358/2024](https://sapl.al.al.leg.br/norma/3117)) and only a ponto facultativo (`"optional"`) before that. +- **PB** — Jul 26 ("Morte de João Pessoa") is emitted up to 2015 only: [Lei PB nº 10.601/2015](https://sapl.al.pb.leg.br/norma/11988), art. 2º, revoked its basis. +- **TO** — Mar 18 ("Autonomia do Estado do Tocantins") is emitted up to 2008 only: [Lei TO nº 2.013/2009](https://www.al.to.leg.br/arquivo/15724) rewrote the clause that declared the feriado into a commemorative provision. + +The statutory date is what is returned. SC's shift above is the only observance shift modelled; Acre's Tuesday-to-Thursday shift and the Goiás decrees that may move Jul 26 and Oct 28 are not. ```javascript import { getHolidays } from '@brazilian-utils/brazilian-utils'; @@ -1272,7 +1388,7 @@ getHolidays({ year: 2024, stateCode: 'SP' }); ### isValidPassport -Check if a Brazilian passport number is valid (2 letters followed by 6 digits). Accepts both `string` and `number` input; the input is case-insensitive and any non-alphanumeric characters (spaces, dots, hyphens) are ignored. +Check if a Brazilian passport number is valid (2 letters followed by 6 digits). Accepts both `string` and `number` input; the input is case-insensitive and any non-alphanumeric characters (spaces, dots, hyphens) are ignored. A number is accepted for symmetry with `formatPassport`/`parsePassport` but is never valid: the decimal form of a number never starts with the two letters a passport number needs. ```javascript import { isValidPassport } from '@brazilian-utils/brazilian-utils'; @@ -1296,7 +1412,7 @@ formatPassport('AB-123.456'); // 'AB123456' ### generatePassport -Generate a random valid Brazilian passport number. +Generate a random valid Brazilian passport number. Uses `Math.random()` internally, so it is not cryptographically secure. ```javascript import { generatePassport } from '@brazilian-utils/brazilian-utils'; @@ -1317,7 +1433,7 @@ parsePassport(' AB 123 456 '); // 'AB123456' ### generateCep -Generate a random CEP. +Generate a random CEP. Uses `Math.random()` internally, so it is not cryptographically secure. ```javascript import { generateCep } from '@brazilian-utils/brazilian-utils'; @@ -1327,7 +1443,7 @@ generateCep(); // '92500000' ### formatCnh -Format CNH. +Format CNH. `options.pad` (part of `FormatCnhOptions`) left-pads the value with zeros to the full 11 digits before masking (default `false`). ```javascript import { formatCnh } from '@brazilian-utils/brazilian-utils'; @@ -1338,17 +1454,19 @@ formatCnh('2650306461', { pad: true }); // 026503064-61 ### isValidCnh -Check if CNH is valid. +Check if CNH is valid. Spaces, dots and hyphens around/between the digits are ignored, but any other character, a letter in particular, makes the value invalid. A value whose 11 digits are all the same is rejected before the check digits are computed, so `'11111111111'` is invalid. ```javascript import { isValidCnh } from '@brazilian-utils/brazilian-utils'; isValidCnh('00000000119'); // true +isValidCnh('000000001-19'); // true (hyphen before the check digits) +isValidCnh('ab00000000119'); // false (letters are rejected) ``` ### generateCnh -Generate a valid random CNH. +Generate a valid random CNH. Uses `Math.random()` internally, so it is not cryptographically secure. ```javascript import { generateCnh } from '@brazilian-utils/brazilian-utils'; @@ -1358,7 +1476,7 @@ generateCnh(); // '02650306461' ### parseCnh -Remove CNH formatting, keep only digits, and cap the result to 11 digits. +Remove CNH formatting, keep only digits, and cap the result to 11 digits. Returns `''` when there is no digit at all. ```javascript import { parseCnh } from '@brazilian-utils/brazilian-utils'; @@ -1368,49 +1486,60 @@ parseCnh('026503064-61'); // '02650306461' ### getCepInfoByAddress -Fetch CEPs from an address using ViaCEP. Throws `GetCepInfoByAddressValidationError` when the UF, city or street is missing/invalid, `GetCepInfoByAddressNotFoundError` when no address matches the query, and `GetCepInfoByAddressError` when ViaCEP itself answers with an HTTP error status. A request that cannot be performed at all (a transport failure) rejects with the underlying `fetch` error instead. +Fetch CEPs from an address using ViaCEP. Throws `GetCepInfoByAddressValidationError` when the UF, city or street is missing/invalid — including when the argument is not an object at all (omitted, `null`, a string) and when `federalUnit` is not a string, neither of which leaks a raw `TypeError` — `GetCepInfoByAddressNotFoundError` when no address matches the query, and `GetCepInfoByAddressError` when ViaCEP itself answers with an HTTP error status. A request that cannot be performed at all (a transport failure) rejects with the underlying `fetch` error instead. Each item is typed as `CepAddressInfo` and carries the ViaCEP payload unchanged, under ViaCEP's own field names: `cep`, `logradouro`, `complemento`, `unidade`, `bairro`, `localidade`, `uf`, `estado`, `regiao`, `ibge`, `gia`, `ddd` and `siafi`. A broad street name matches many CEPs, so query as narrowly as the address allows. ```javascript import { getCepInfoByAddress } from '@brazilian-utils/brazilian-utils'; const ceps = await getCepInfoByAddress({ - federalUnit: 'SP', - city: 'Sao Paulo', - street: 'Avenida Paulista' + federalUnit: 'MG', + city: 'Ouro Preto', + street: 'Rua Direita' }); // [ // { -// cep: '01310100', -// logradouro: 'Avenida Paulista', -// complemento: 'lado par', -// bairro: 'Bela Vista', -// localidade: 'São Paulo', -// uf: 'SP' +// cep: '35411-152', +// logradouro: 'Rua Direita', +// complemento: '', +// unidade: '', +// bairro: 'Riacho (Amarantina)', +// localidade: 'Ouro Preto', +// uf: 'MG', +// estado: 'Minas Gerais', +// regiao: 'Sudeste', +// ibge: '3146107', +// gia: '', +// ddd: '31', +// siafi: '4921' // } // ] ``` ### generateProcessoJuridico -Generate a valid random processo jurídico number according to [CNJ's definition](https://atos.cnj.jus.br/atos/detalhar/119). `year` must be between the current year and 9999, `court` between 1 and 9; out-of-range values return `null`. Uses `Math.random()` internally, so it is not cryptographically secure. +Generate a valid random processo jurídico number according to [CNJ's definition](https://atos.cnj.jus.br/atos/detalhar/119). `year` must be between the current year and 9999, `court` between 1 and 9; out-of-range values return `null`. The órgão (`J`) and the tribunal (`TR`) are drawn from the closed lists of art. 1º, § 4º and § 5º, so the pair always names a court that exists: `court` picks the órgão and the `TR` is drawn among the tribunais that órgão has. The unidade de origem (`OOOO`) is drawn freely, since the resolution publishes no central list for it. Uses `Math.random()` internally, so it is not cryptographically secure. ```javascript import { generateProcessoJuridico } from '@brazilian-utils/brazilian-utils'; -generateProcessoJuridico(); // '89478643020269670326' -generateProcessoJuridico({ year: 2026, court: 5 }); // string | null +generateProcessoJuridico(); // '89478645020266070326' +generateProcessoJuridico({ year: 2026, court: 5 }); // '98412562120265087260' (Justiça do Trabalho, TRT da 8ª Região) generateProcessoJuridico({ year: 10000 }); // null (year out of range) +generateProcessoJuridico({ court: 10 }); // null (no such órgão) ``` ### formatLegalNature -Format a legal nature code. +Format a legal nature code. `options.pad` (part of `FormatLegalNatureOptions`) works exactly like it does in `formatCpf`/`formatCep`: with the default `false` the mask is applied progressively, as far as the value goes; with `true` the value is first left padded with zeros to the 4 digits of a complete code. Use `isValidLegalNature` to check a code. ```javascript import { formatLegalNature } from '@brazilian-utils/brazilian-utils'; formatLegalNature('2062'); // 206-2 +formatLegalNature(2062); // 206-2 +formatLegalNature('206'); // 206 (masked as far as it goes) +formatLegalNature('62', { pad: true }); // 006-2 (padded to 4 digits first) ``` ### isValidLegalNature @@ -1426,7 +1555,7 @@ isValidLegalNature('9999'); // false ### generateLegalNature -Generate a random valid legal nature code. +Generate a random valid legal nature code. Uses `Math.random()` internally, so it is not cryptographically secure. ```javascript import { generateLegalNature } from '@brazilian-utils/brazilian-utils'; @@ -1456,20 +1585,45 @@ const legalNatures = getLegalNatures(); legalNatures['2062']; // 'Sociedade Empresária Limitada' ``` +### getLegalNaturesByCategory + +Get every legal nature of a CONCLA category, the group given by the first digit of the code: `1` Administração Pública, `2` Entidades Empresariais, `3` Entidades sem Fins Lucrativos, `4` Pessoas Físicas and `5` Organizações Internacionais e Outras Instituições Extraterritoriais. The category is accepted as a string or as a number, the entries come back sorted by code, and an unknown category gives `[]`. + +```javascript +import { getLegalNaturesByCategory } from '@brazilian-utils/brazilian-utils'; + +getLegalNaturesByCategory('4')[0]; +// { +// code: '4014', +// description: 'Empresa Individual Imobiliária', +// category: { code: '4', description: 'Pessoas Físicas' }, +// } +getLegalNaturesByCategory(4).length; // 6 +getLegalNaturesByCategory('2').length; // 33 +getLegalNaturesByCategory('9'); // [] +``` + ### getLegalNature -Look a legal nature code up in the official IBGE/CONCLA table. +Look a legal nature code up in the official IBGE/CONCLA table. The entry also carries the CONCLA category the code is listed under, taken from its first digit. No legal nature code starts with a zero, that first digit is the category (1 to 5), so nothing is ever padded here: a number and the string of the same digits are read identically. ```javascript import { getLegalNature } from '@brazilian-utils/brazilian-utils'; -getLegalNature('2062'); // { code: '2062', description: 'Sociedade Empresária Limitada' } +getLegalNature('2062'); +// { +// code: '2062', +// description: 'Sociedade Empresária Limitada', +// category: { code: '2', description: 'Entidades Empresariais' }, +// } +getLegalNature('206-2')?.code; // '2062' +getLegalNature(206.2)?.category.description; // 'Entidades Empresariais' getLegalNature('0000'); // null ``` ### generatePhone -Generate a random Brazilian phone number. Accepts `'mobile'`, `'landline'` or `'service'` (typed as `GeneratePhoneType`); a service number has no DDD. Omitted, it randomly generates a mobile or a landline, never a service number. +Generate a random Brazilian phone number. Accepts `'mobile'`, `'landline'` or `'service'` (typed as `GeneratePhoneType`); a service number has no DDD. Omitted, it randomly generates a mobile or a landline, never a service number. A generated mobile number always starts with 9, so it passes both `isValidMobilePhone` numbering rules. ```javascript import { generatePhone } from '@brazilian-utils/brazilian-utils'; @@ -1482,7 +1636,7 @@ generatePhone('service'); // '08001234567' or '40041234' ### formatLicensePlate -Format a license plate. Old Brazilian plates (`LLLNNNN`) are returned with a hyphen and Mercosul plates (`LLLNLNN`) stay normalized. +Format a license plate. Old Brazilian plates (`LLLNNNN`) are returned with a hyphen and Mercosul plates (`LLLNLNN`) stay normalized. Partial values are formatted as far as they go, so it can also be used as an input mask, and a value that cannot start a valid plate gives `''`. ```javascript import { formatLicensePlate } from '@brazilian-utils/brazilian-utils'; @@ -1493,7 +1647,7 @@ formatLicensePlate('abc1d23'); // 'ABC1D23' ### generateLicensePlate -Generate a random license plate in the chosen format. +Generate a random license plate in the chosen format. Uses `Math.random()` internally, so it is not cryptographically secure. ```javascript import { generateLicensePlate } from '@brazilian-utils/brazilian-utils'; @@ -1502,6 +1656,8 @@ generateLicensePlate(); // 'ABC1D23' (Mercosul, the default) generateLicensePlate('LLLNNNN'); // 'ABC1234' ``` +A `format` string outside the two supported literals is not rejected: it is used verbatim, character by character, with `L` producing a letter and every other position a digit. So `generateLicensePlate('LLLNNLN')` returns a plate in the withdrawn motorcycle sequence, which `isValidLicensePlate` rejects; `generateLicensePlate('bogus')` returns five digits; and `generateLicensePlate('')` returns an empty string. Only a non-string falls back to the Mercosul default. This is the 2.3.0 behaviour, kept for the JavaScript callers the TypeScript type cannot reach. + ### getFormatLicensePlate Detect the normalized format of a license plate. @@ -1542,7 +1698,7 @@ convertLicensePlateToMercosul('ABC1D23'); // '' (already Mercosul) ### generatePis -Generate a valid random PIS. +Generate a valid random PIS. Uses `Math.random()` internally, so it is not cryptographically secure. ```javascript import { generatePis } from '@brazilian-utils/brazilian-utils'; @@ -1552,7 +1708,7 @@ generatePis(); // '91077906857' ### getMunicipality -Get municipality information by IBGE code, or get an IBGE code from municipality name and UF. A single function handles both directions, based on whether `options` has a `code` or a `municipalityName`/`uf`. `code` accepts both `string` and `number` input and must be exactly 7 digits, otherwise the function resolves to `null`. A `code` given as a number must be a non-negative integer: a sign and a decimal point are not digits, so `-3550308` and `355030.8` resolve to `null` instead of being read as `3550308`. Resolution is entirely offline, from a bundled IBGE dataset: no network request is made. The municipality name match ignores accents and casing. An unknown municipality, an unknown UF or invalid input all resolve to `null`. +Get municipality information by IBGE code, or get an IBGE code from municipality name and UF. **Deprecated:** use `getMunicipalityByCode` instead, which is synchronous and offline; matching a municipality by name is up to the application, over `getMunicipalities`. A single function handles both directions, based on whether `options` has a `code` or a `municipalityName`/`uf`. `code` accepts both `string` and `number` input and must be exactly 7 digits, otherwise the function resolves to `null`. A `code` given as a number must be a non-negative integer: a sign and a decimal point are not digits, so `-3550308` and `355030.8` resolve to `null` instead of being read as `3550308`. Resolution is entirely offline, from a bundled IBGE dataset: no network request is made. The municipality name match ignores accents and casing, and every run of whitespace collapses into a single space, so `'sao paulo'` matches `'São Paulo'` while a name written without the space does not; the casing is folded to upper case, the direction Unicode expands `'ß'` to `'SS'` in, so `'Paßos'` matches `'Passos'`. An unknown municipality, an unknown UF or invalid input all resolve to `null`. The `[name, uf]` pair is a fresh array on every call, so mutating the result never affects subsequent lookups. ```javascript import { getMunicipality } from '@brazilian-utils/brazilian-utils'; @@ -1573,9 +1729,32 @@ await getMunicipality({ code: '123' }); // null (not 7 digits) ``` +In TypeScript the return type follows the direction of the lookup: a `{ code }` query resolves to `[string, string] | null`, a `{ municipalityName, uf }` query resolves to `string | null`, and a query whose direction is only known at run time (a variable typed as `GetMunicipalityOptions`) resolves to the union of both. + +```typescript +import { + getMunicipality, + type GetMunicipalityByCodeOptions, + type GetMunicipalityByNameOptions, + type GetMunicipalityOptions, +} from '@brazilian-utils/brazilian-utils'; + +const byCode: GetMunicipalityByCodeOptions = { code: '3550308' }; +const byName: GetMunicipalityByNameOptions = { municipalityName: 'sao paulo', uf: 'sp' }; + +await getMunicipality(byCode); +// Promise<[string, string] | null> + +await getMunicipality(byName); +// Promise + +const lookUp = (options: GetMunicipalityOptions) => getMunicipality(options); +// (options: GetMunicipalityOptions) => Promise<[string, string] | string | null> +``` + ### getMunicipalities -Get Brazilian municipalities published by the IBGE. Returns all municipalities if no state is provided, or municipalities from a specific state. Each municipality is returned as `{ code, name, stateCode }`, where `code` is the 7-digit IBGE municipality code. Results are sorted by name with `localeCompare` in the "pt-BR" locale. Each call returns a fresh array of fresh objects, so mutating the result never affects subsequent calls. An unknown state code returns an empty array instead of throwing. +Get Brazilian municipalities published by the IBGE. Returns all municipalities if no state is provided, or municipalities from a specific state. Each municipality is returned as `{ code, name, stateCode }`, where `code` is the 7-digit IBGE municipality code. Results are sorted by name with `localeCompare` in the "pt-BR" locale. Each call returns a fresh array of fresh objects, so mutating the result never affects subsequent calls. An unknown state code returns an empty array instead of throwing. Only an omitted (or `undefined`) `stateCode` asks for the full list: `getMunicipalities(null)` and `getMunicipalities('')` return `[]`, where the looser `getCities(null)` and `getCities('')` return every city. The state code is matched exactly, case included: `getMunicipalities('sp')` returns `[]` where `getMunicipalities('SP')` returns the 645 São Paulo municipalities. `getMunicipalities` and `getCities` are the only state-taking lookups that are case-sensitive; `getStateNameByCode`, `getTimezoneByState`, `getAreaCodesByState` and `getMunicipality` all fold case. ```javascript import { getMunicipalities } from '@brazilian-utils/brazilian-utils'; @@ -1623,10 +1802,9 @@ getMunicipalityByCode(3550308); getMunicipalityByCode('0000000'); // null (unknown code) getMunicipalityByCode('123'); // null (not 7 digits) ``` - ### isHoliday -Check if a specific date is a Brazilian holiday. The check compares `targetDate`'s local calendar date (year/month/day as read locally), not its underlying UTC instant. Returns `false` when `targetDate` is missing or not a valid `Date`. +Check if a specific date is a Brazilian holiday. The check compares `targetDate`'s local calendar date (year/month/day as read locally), not its underlying UTC instant. Returns `false` when `targetDate` is missing or not a valid `Date`. An invalid `stateCode` is treated in two different ways: a string that is not a known state code is ignored and only national holidays are considered, the same as `getHolidays`, while a `stateCode` that is present and is not a string at all (a number, `null`, an object) is rejected and makes the call return `false` even for a national holiday. ```javascript import { isHoliday } from '@brazilian-utils/brazilian-utils'; @@ -1638,7 +1816,7 @@ isHoliday(); // false ### isBusinessDay -Check if a date is a Brazilian business day (dia útil). Returns `false` for Saturdays, Sundays, and Brazilian holidays returned by `getHolidays` for `value`'s local calendar day (year/month/day as read locally), the same convention used by `isHoliday`. `options.includeOptional` (part of `IsBusinessDayOptions`) defaults to `true`, so optional-type holidays (`Holiday.type === "optional"`, i.e. Carnaval and Corpus Christi) also count as non-business days; pass `false` to only treat statutory holidays this way. `options.stateCode` also considers that state's holidays; an unknown/invalid `stateCode` is ignored, falling back to national holidays only. A `value` that is not a valid `Date` returns `false`. Only years from 1900 through 2099 are supported, the range `getHolidays` computes; a date outside it returns `false`. +Check if a date is a Brazilian business day (dia útil). Returns `false` for Saturdays, Sundays, and Brazilian holidays returned by `getHolidays` for `value`'s local calendar day (year/month/day as read locally), the same convention used by `isHoliday`. `options.includeOptional` (part of `BusinessDayOptions`, the option type every business day utility shares) defaults to `true`, so optional-type holidays (`Holiday.type === "optional"`, i.e. Carnaval and Corpus Christi) also count as non-business days; pass `false` to only treat statutory holidays this way. `options.stateCode` also considers that state's holidays; a string that is not a known state code is ignored, falling back to national holidays only, while a `stateCode` that is present and is not a string at all (a number, `null`, an object) is rejected and makes the call return `false` even for an ordinary weekday, the same split `isHoliday` makes and the value `addBusinessDays`, `subBusinessDays` and `differenceInBusinessDays` reject with `null`. A `value` that is not a valid `Date` returns `false`. Only years from 1900 through 2099 are supported, the range `getHolidays` computes; a date outside it returns `false`. ```javascript import { isBusinessDay } from '@brazilian-utils/brazilian-utils'; @@ -1655,38 +1833,55 @@ isBusinessDay(new Date('not a date')); // false ### addBusinessDays -Add a number of Brazilian business days (dias úteis) to a date, skipping Saturdays, Sundays and Brazilian holidays exactly as `isBusinessDay` defines them (same `stateCode`/`includeOptional` options). Returns a new `Date`; the input `date` (part of `AddBusinessDaysParams`) is never mutated, and its time-of-day is preserved in the result. `days: 0` returns a new `Date` equal to `date`, unchanged, even when `date` itself falls on a weekend or holiday, this mirrors the verified behavior of [date-fns' `addBusinessDays(date, 0)`](https://date-fns.org/docs/addBusinessDays), which also does not roll the input to the next business day. A negative `days` walks backwards, one business day at a time, also like date-fns. Returns `null` on bad input: a `date` that is not a valid `Date`, a `days` that is not a finite integer, or a `stateCode` that is not a string. Only years from 1900 through 2099 are supported, the range `getHolidays` computes; a date outside it (or, for `addBusinessDays`, a walk that leaves it) returns `null`. +Add a number of Brazilian business days (dias úteis) to a date, skipping Saturdays, Sundays and Brazilian holidays exactly as `isBusinessDay` defines them (same `BusinessDayOptions`: `options.includeOptional`, default `true`, and `options.stateCode` work exactly as they do there). The signature is date-fns': `addBusinessDays(date, amount, options?)`. Returns a new `Date`; the input `date` is never mutated, and its time-of-day is preserved in the result. An `amount` of `0` returns a new `Date` equal to `date`, unchanged, even when `date` itself falls on a weekend or holiday, this mirrors the verified behavior of [date-fns' `addBusinessDays(date, 0)`](https://date-fns.org/docs/addBusinessDays), which also does not roll the input to the next business day. A negative `amount` walks backwards, one business day at a time, also like date-fns. Returns `null` on bad input: a `date` that is not a valid `Date`, an `amount` that is not a finite integer, or a `stateCode` that is not a string; an `options` that is not an object at all is ignored, exactly as `isBusinessDay` ignores it. Only years from 1900 through 2099 are supported, the range `getHolidays` computes; a date outside it, or a walk that leaves it, returns `null`. ```javascript import { addBusinessDays } from '@brazilian-utils/brazilian-utils'; -addBusinessDays({ date: new Date(2024, 0, 2, 12), days: 1 }); // Date, 2024-01-03 12:00 (next day is already a business day) -addBusinessDays({ date: new Date(2024, 11, 31, 12), days: 1 }); // Date, 2025-01-02 12:00 (2025-01-01 is Ano novo, skipped) -addBusinessDays({ date: new Date(2024, 0, 5, 12), days: -1 }); // Date, 2024-01-04 12:00 (walks backwards) -addBusinessDays({ date: new Date(2024, 0, 6, 12), days: 0 }); // Date, 2024-01-06 12:00 (unchanged, even though Saturday is not a business day) -addBusinessDays({ date: new Date(2024, 6, 8, 12), days: 1, stateCode: 'SP' }); // Date, 2024-07-10 12:00 (2024-07-09 is Revolução Constitucionalista in SP, skipped) -addBusinessDays({ date: new Date('not a date'), days: 1 }); // null -addBusinessDays({ date: new Date(2024, 0, 2), days: 1.5 }); // null (not an integer) +addBusinessDays(new Date(2024, 0, 2, 12), 1); // Date, 2024-01-03 12:00 (next day is already a business day) +addBusinessDays(new Date(2024, 11, 31, 12), 1); // Date, 2025-01-02 12:00 (2025-01-01 is Ano novo, skipped) +addBusinessDays(new Date(2024, 0, 5, 12), -1); // Date, 2024-01-04 12:00 (walks backwards) +addBusinessDays(new Date(2024, 0, 6, 12), 0); // Date, 2024-01-06 12:00 (unchanged, even though Saturday is not a business day) +addBusinessDays(new Date(2024, 6, 8, 12), 1, { stateCode: 'SP' }); // Date, 2024-07-10 12:00 (2024-07-09 is Revolução Constitucionalista in SP, skipped) +addBusinessDays(new Date('not a date'), 1); // null +addBusinessDays(new Date(2024, 0, 2), 1.5); // null (not an integer) +``` + +### subBusinessDays + +Subtract a number of Brazilian business days (dias úteis) from a date: `subBusinessDays(date, amount, options?)` is `addBusinessDays(date, -amount, options)`, which is exactly how it is implemented, so every detail above (the preserved time-of-day, the untouched input, an `amount` of `0` returning the date unchanged, the 1900-2099 range and the `null` cases) holds here too, `options.stateCode` and `options.includeOptional` included. A negative `amount` walks forwards. + +```javascript +import { subBusinessDays } from '@brazilian-utils/brazilian-utils'; + +subBusinessDays(new Date(2024, 0, 5, 12), 1); // Date, 2024-01-04 12:00 (previous day is already a business day) +subBusinessDays(new Date(2024, 0, 8, 12), 1); // Date, 2024-01-05 12:00 (walks back over the weekend) +subBusinessDays(new Date(2025, 0, 2, 12), 1); // Date, 2024-12-31 12:00 (2025-01-01 is Ano novo, skipped) +subBusinessDays(new Date(2024, 0, 5, 12), -1); // Date, 2024-01-08 12:00 (walks forwards) +subBusinessDays(new Date(2024, 0, 6, 12), 0); // Date, 2024-01-06 12:00 (unchanged, even though Saturday is not a business day) +subBusinessDays(new Date(2024, 6, 10, 12), 1, { stateCode: 'SP' }); // Date, 2024-07-08 12:00 (2024-07-09 is Revolução Constitucionalista in SP, skipped) +subBusinessDays(new Date('not a date'), 1); // null +subBusinessDays(new Date(2024, 0, 2), 1.5); // null (not an integer) ``` ### differenceInBusinessDays -Count the number of Brazilian business days (dias úteis) between two dates, mirroring the semantics of [date-fns' `differenceInBusinessDays`](https://date-fns.org/docs/differenceInBusinessDays) (verified against its source): `params.from` is counted when it is itself a business day, `params.to` is never counted, and every business day strictly in between is counted once. Only the calendar day of each `Date` matters, the time of day is ignored. Business days are determined exactly like `isBusinessDay` (same `stateCode`/`includeOptional` options). `from`/`to` on the same calendar day return `0`; a `to` before `from` returns a negative number. Returns `null` on bad input: a `from`/`to` that is not a valid `Date`, or a `stateCode` that is not a string. Parameters are typed as `DifferenceInBusinessDaysParams`. Only years from 1900 through 2099 are supported, the range `getHolidays` computes; a date outside it (or, for `addBusinessDays`, a walk that leaves it) returns `null`. +Count the number of Brazilian business days (dias úteis) between two dates, mirroring the semantics of [date-fns' `differenceInBusinessDays`](https://date-fns.org/docs/differenceInBusinessDays) (verified against its source), argument order included: `differenceInBusinessDays(laterDate, earlierDate, options?)`. The walk starts at `earlierDate` and stops just before `laterDate`, so `earlierDate` is counted when it is itself a business day, `laterDate` is never counted, and every business day strictly in between is counted once. Only the calendar day of each `Date` matters, the time of day is ignored. Business days are determined exactly like `isBusinessDay` (same `BusinessDayOptions`), `options.includeOptional` (default `true`) and `options.stateCode` included. The result is positive when `laterDate` is after `earlierDate` and negative when it is before it; two dates on the same calendar day return `0`. Returns `null` on bad input: a date that is not a valid `Date`, or a `stateCode` that is not a string; an `options` that is not an object at all is ignored. Only years from 1900 through 2099 are supported, the range `getHolidays` computes; a date outside it returns `null`. ```javascript import { differenceInBusinessDays } from '@brazilian-utils/brazilian-utils'; -differenceInBusinessDays({ from: new Date(2024, 0, 1), to: new Date(2024, 0, 2) }); // 0 (Jan 1 is Ano novo) -differenceInBusinessDays({ from: new Date(2024, 0, 2), to: new Date(2024, 0, 3) }); // 1 (Jan 2 counted, a Tuesday) -differenceInBusinessDays({ from: new Date(2024, 0, 3), to: new Date(2024, 0, 2) }); // -1 (to before from) -differenceInBusinessDays({ from: new Date(2024, 0, 2), to: new Date(2024, 0, 2) }); // 0 (same day) -differenceInBusinessDays({ from: new Date(2024, 6, 8), to: new Date(2024, 6, 10), stateCode: 'SP' }); // 1 (2024-07-09 is a state holiday in SP) -differenceInBusinessDays({ from: new Date('not a date'), to: new Date() }); // null +differenceInBusinessDays(new Date(2024, 0, 2), new Date(2024, 0, 1)); // 0 (Jan 1 is Ano novo, not counted) +differenceInBusinessDays(new Date(2024, 0, 3), new Date(2024, 0, 2)); // 1 (Jan 2 counted, a Tuesday; Jan 3 is not) +differenceInBusinessDays(new Date(2024, 0, 2), new Date(2024, 0, 3)); // -1 (the later date comes first, so the count is negative) +differenceInBusinessDays(new Date(2024, 0, 2), new Date(2024, 0, 2)); // 0 (same day) +differenceInBusinessDays(new Date(2024, 6, 10), new Date(2024, 6, 8), { stateCode: 'SP' }); // 1 (2024-07-09 is a state holiday in SP) +differenceInBusinessDays(new Date(), new Date('not a date')); // null ``` ### convertDateToWords -Formats a date as its Brazilian Portuguese "por extenso" textual representation, e.g. `"01/01/2024"` becomes `"primeiro de janeiro de dois mil e vinte e quatro"`. Accepts a `Date` (read by its local calendar date, the same convention used by `isHoliday`) or a string in `"dd/mm/yyyy"` or ISO `"yyyy-mm-dd"` format. With the default `options.style` of `"full"`, day 1 is written as "primeiro" and every other day uses the cardinal number; with `"month"`, only the month name is spelled out and the day/year are left as digits (day 1 as `"1º"`, e.g. `"2 de março de 2024"`, `"1º de janeiro de 2024"`). Month names are lowercase. In `"full"` style the year is written out as a cardinal number without the thousands comma that `convertNumberToWords`/`convertCurrencyToWords` use (`1999` reads as `"mil novecentos e noventa e nove"`, not `"mil, novecentos e noventa e nove"`), matching how a date is read aloud. `options.weekday` (default `false`) prefixes the pt-BR weekday name in lowercase followed by a comma (`"sábado, dois de março de dois mil e vinte e quatro"`), computed from the resolved calendar date. `options.case` sets the letter case of the whole result: `"lower"` (default), `"sentence"` (capitalizes only the first letter) or `"upper"` (uppercases everything, keeping accents). Invalid `case`/`style` values are ignored and the default is used. February 29th is accepted on the leap years of the proleptic Gregorian calendar (divisible by 4, except centuries not divisible by 400). Returns `""` for an invalid `Date`, a malformed string, a day/month that does not exist, or a date before year 1. +Formats a date as its Brazilian Portuguese "por extenso" textual representation, e.g. `"01/01/2024"` becomes `"primeiro de janeiro de dois mil e vinte e quatro"`. Accepts a `Date` (read by its local calendar date, the same convention used by `isHoliday`) or a string in `"dd/mm/yyyy"` or ISO `"yyyy-mm-dd"` format. With the default `options.style` of `"full"`, day 1 is written as "primeiro" and every other day uses the cardinal number; with `"month"`, only the month name is spelled out and the day/year are left as digits (day 1 as `"1º"`, e.g. `"2 de março de 2024"`, `"1º de janeiro de 2024"`). Month names are lowercase. In `"full"` style the year is written out as a cardinal number without the thousands comma that `convertNumberToWords`/`convertCurrencyToWords` use (`1999` reads as `"mil novecentos e noventa e nove"`, not `"mil novecentos e noventa e nove"`), matching how a date is read aloud. `options.weekday` (default `false`) prefixes the pt-BR weekday name in lowercase followed by a comma (`"sábado, dois de março de dois mil e vinte e quatro"`), computed from the resolved calendar date. An invalid `style` value is ignored and the default is used. The result is always lowercase; apply any other casing to it yourself. February 29th is accepted on the leap years of the proleptic Gregorian calendar (divisible by 4, except centuries not divisible by 400). Returns `""` for an invalid `Date`, a malformed string, a day/month that does not exist, or a date before year 1. ```javascript import { convertDateToWords } from '@brazilian-utils/brazilian-utils'; @@ -1694,7 +1889,6 @@ import { convertDateToWords } from '@brazilian-utils/brazilian-utils'; convertDateToWords('01/01/2024'); // "primeiro de janeiro de dois mil e vinte e quatro" convertDateToWords('2024-01-02'); // "dois de janeiro de dois mil e vinte e quatro" convertDateToWords(new Date(2024, 0, 1)); // "primeiro de janeiro de dois mil e vinte e quatro" -convertDateToWords('01/01/2024', { case: 'sentence' }); // "Primeiro de janeiro de dois mil e vinte e quatro" convertDateToWords('02/03/2024', { style: 'month' }); // "2 de março de 2024" convertDateToWords('01/01/2024', { style: 'month' }); // "1º de janeiro de 2024" convertDateToWords('02/03/2024', { weekday: true }); // "sábado, dois de março de dois mil e vinte e quatro" @@ -1717,7 +1911,7 @@ formatVoterId('1234567880191'); // '1234 5678 8 01 91' (13-digit SP/MG voter id) ### isValidVoterId -Check if a voter ID number is valid. Accepts both the standard 12-digit id and the 13-digit id issued by São Paulo (UF `01`) and Minas Gerais (UF `02`). +Check if a voter ID number is valid. Accepts both the standard 12-digit id and the 13-digit id issued by São Paulo (UF `01`) and Minas Gerais (UF `02`). Whitespace and dots are accepted around and between the `0000 0000 00 00` groups, but any other character, a letter in particular, makes the value invalid. ```javascript import { generateVoterId, isValidVoterId } from '@brazilian-utils/brazilian-utils'; @@ -1752,34 +1946,47 @@ parseVoterId('1234 5678 8 01 91'); // '1234567880191' (13-digit SP/MG voter id) ### isValidCns -Check if a CNS (Cartão Nacional de Saúde) number is valid, the unique SUS (Sistema Único de Saúde) user identifier. Definitive cards (starting with 1 or 2) are validated with the same mod 11 weighting used for PIS numbers over an embedded 11 digit base, adjusting the base by +2 when the raw check digit computes to 10. Provisional cards (starting with 7, 8 or 9) are validated instead by a single weighted sum (weights 15 down to 1) that must be a multiple of 11. The value has to be written as the 15 digits, optionally split into the printed groups of 3-4-4-4 by whitespace or the usual mask characters; letters among the digits are rejected instead of being read past. +Check if a CNS (Cartão Nacional de Saúde) number is valid, the unique SUS (Sistema Único de Saúde) user identifier. Definitive cards (starting with 1 or 2) are validated over an embedded 11 digit PIS/PASEP/NIS derived base weighted 15 down to 5; when the raw digit computes to 10, DATASUS raises the weighted sum by 2, recomputes the digit and marks the card with the suffix `001` instead of `000`. Provisional cards (starting with 7, 8 or 9) are validated instead by a single weighted sum (weights 15 down to 1) that must be a multiple of 11. The value has to be written as the 15 digits, optionally split into the printed groups of 3-4-4-4 by whitespace, `.`, `-` or `/`, the interchangeable mask characters `isValidCpf` and `isValidCnpj` accept, a run of them between two groups included; letters among the digits, or a separator inside a group, are rejected instead of being read past. + +The two routines come from the [ANVISA CNS validation page](https://rni-docs.anvisa.gov.br/docs/regras_gerais/validacoes/validacaoCNS/), which sits behind a bot filter and answers HTTP 403 to non-browser clients. The [e-SUS APS page](https://integracao.esusab.ufsc.br/ledi/documentacao/regras/algoritmo_CNS.html) documents the same algorithm and is reachable without a browser, but applies the provisional routine to numbers starting with 5, 7, 8 or 9; this implementation follows ANVISA and rejects a 5-prefixed number even when its weighted sum checks out. ```javascript import { isValidCns } from '@brazilian-utils/brazilian-utils'; isValidCns('123456789010000'); // true (definitive) isValidCns('700000000000005'); // true (provisional) +isValidCns('123.4567-8901/0000'); // true (any of the mask characters) isValidCns('12345678901'); // false (wrong length) isValidCns('abc123456789010000'); // false (not written as a CNS) ``` ### formatCns -Format a CNS (Cartão Nacional de Saúde) number into the common display groups of 3-4-4-4 digits separated by spaces. Options are typed as `FormatCnsOptions`. +Format a CNS (Cartão Nacional de Saúde) number into the common display groups of 3-4-4-4 digits separated by spaces. `options.pad` (part of `FormatCnsOptions`) left-pads the value with zeros up to the 15 slots of the pattern before masking (default `false`). ```javascript import { formatCns } from '@brazilian-utils/brazilian-utils'; -formatCns('123456789010001'); // '123 4567 8901 0001' -formatCns(123456789010001); // '123 4567 8901 0001' +formatCns('123456789010000'); // '123 4567 8901 0000' +formatCns(123456789010000); // '123 4567 8901 0000' formatCns('89010001', { pad: true }); // '000 0000 8901 0001' ``` +### parseCns + +Remove CNS (Cartão Nacional de Saúde) formatting, keep only digits, and cap the result to 15 digits. A partial value passes through as far as it goes, so it can also strip the mask off an input still being typed; use `isValidCns` to check the number itself. + +```javascript +import { parseCns } from '@brazilian-utils/brazilian-utils'; + +parseCns('123 4567 8901 0000'); // '123456789010000' +``` + ### isValidCertidao -Check if the matrícula of a certidão de registro civil (nascimento, casamento, óbito and the other acts kept by a serventia de registro civil das pessoas naturais) is valid. The matrícula has 32 digits laid out as 6 (CNS da serventia) + 2 (acervo) + 2 (serviço) + 4 (ano) + 1 (tipo do livro) + 5 (livro) + 3 (folha) + 7 (termo) + 2 (dígitos verificadores), and both check digits are modulus 11 with weights cycling from 2 to 10 and back through 0. Accepts the usual mask characters and whitespace between/around groups. The layout is the in-force one of [art. 473 of the Código Nacional de Normas da Corregedoria Nacional de Justiça](https://atos.cnj.jus.br/atos/detalhar/5243) (Provimento CNJ nº 149/2023, in the wording of the Provimento CN nº 182/2024); the matrícula itself was instituted by the now revoked [Provimento CNJ nº 2/2009](https://atos.cnj.jus.br/atos/detalhar/1311). The check digits are detailed by [ghiorzi.org](http://ghiorzi.org/DVnew.htm) and implemented by [validation-br](https://github.com/klawdyo/validation-br/blob/feat-certidao/src/certidao.ts) and [validator-docs](https://github.com/geekcom/validator-docs/blob/master/src/validator-docs/Rules/Certidao.php). +Check if the matrícula of a certidão de registro civil (nascimento, casamento, óbito and the other acts kept by a serventia de registro civil das pessoas naturais) is valid. The matrícula has 32 digits laid out as 6 (CNS da serventia) + 2 (acervo) + 2 (serviço) + 4 (ano) + 1 (tipo do livro) + 5 (livro) + 3 (folha) + 7 (termo) + 2 (dígitos verificadores), and both check digits are modulus 11 with the weights cycling from 2 to 10 and back through 0: the first pass starts at 2 over the 30 base digits, the second at 1 over the 31 digits that include the first check digit, and in both a remainder of 10 is read as 1. Accepts the usual mask characters and whitespace between/around groups. The layout is the one [art. 473 of the Código Nacional de Normas da Corregedoria Nacional de Justiça](https://atos.cnj.jus.br/atos/detalhar/5243) (Provimento CNJ nº 149/2023) currently publishes, with inciso II and §§ 1º and 3º to 5º in the redação of the Provimento CN nº 237/2026 and the rest of the article, § 2º included, in that of the Provimento CN nº 182/2024; the matrícula itself was instituted by the now revoked [Provimento CNJ nº 2/2009](https://atos.cnj.jus.br/atos/detalhar/1311) and got its digit structure from the also revoked [Provimento CNJ nº 3/2009, art. 7º](https://atos.cnj.jus.br/atos/detalhar/1310). The check digits are detailed by [ghiorzi.org](http://ghiorzi.org/DVnew.htm) and implemented by [validation-br](https://github.com/klawdyo/validation-br/blob/feat-certidao/src/certidao.ts) and [validator-docs](https://github.com/geekcom/validator-docs/blob/master/src/validator-docs/Rules/Certidao.php). -The serviço digits are fixed at `55`, the code [art. 473, III](https://atos.cnj.jus.br/atos/detalhar/5243) assigns to the registro civil das pessoas naturais, so a matrícula carrying any other pair in the ninth and tenth positions is rejected however good its check digits are. The book-type digit always has to name one of the nine book types (the same `CertidaoType` returned by `parseCertidao`), so a matrícula whose digit is `0` is rejected however good its check digits are, the same way `parseCertidao` returns `null` for it. `options.accept` (part of `IsValidCertidaoOptions`) narrows that to the listed types; it defaults to every type, and a value that is not an array falls back to that default. Only a string is accepted: the 32 digits of a matrícula are more than a JavaScript number can hold. +The serviço digits are fixed at `55`, the code [art. 473, III](https://atos.cnj.jus.br/atos/detalhar/5243) assigns to the registro civil das pessoas naturais, so a matrícula carrying any other pair in the ninth and tenth positions is rejected however good its check digits are. The book-type digit always has to name one of the nine book types (the same `CertidaoType` returned by `getCertidaoInfo`), so a matrícula whose digit is `0` is rejected however good its check digits are, the same way `getCertidaoInfo` returns `null` for it. `options.accept` (part of `IsValidCertidaoOptions`) narrows that to the listed types; it defaults to every type, and a value that is not an array falls back to that default. Only a string is accepted: the 32 digits of a matrícula are more than a JavaScript number can hold. ```javascript import { isValidCertidao } from '@brazilian-utils/brazilian-utils'; @@ -1793,14 +2000,38 @@ isValidCertidao('104539 01 55 2013 1 00012 021 0000123 21', { accept: ['birth'] isValidCertidao('104539 01 55 2013 1 00012 021 0000123 21', { accept: ['death'] }); // false ``` +### formatCertidao + +Format the matrícula of a certidão de registro civil into the printed mask of the Provimento, the 32 digits grouped as 6 2 2 4 1 5 3 7 2 and separated by spaces. `options.pad` (part of `FormatCertidaoOptions`) left pads the value with zeros up to 32 digits (default `false`). The mask is the one of [art. 473 of the Código Nacional de Normas da Corregedoria Nacional de Justiça](https://atos.cnj.jus.br/atos/detalhar/5243). A number is accepted and read as the string of its digits, like in `formatCpf`, but a full 32 digit matrícula has to be a string: that many digits are more than a JavaScript number can hold exactly. At runtime the value is read for its digits and masked as far as they go, like in every formatter of this package, so a partial matrícula still being typed is masked progressively. + +```javascript +import { formatCertidao } from '@brazilian-utils/brazilian-utils'; + +formatCertidao('10453901552013100012021000012321'); // 104539 01 55 2013 1 00012 021 0000123 21 +formatCertidao('104539.01.55.2013.1.00012.021.0000123-21'); // 104539 01 55 2013 1 00012 021 0000123 21 +formatCertidao('1552010100020112000012087', { pad: true }); // 000000 01 55 2010 1 00020 112 0000120 87 +formatCertidao(104539015520); // 104539 01 55 20 (a number is read as the string of its digits) +``` + ### parseCertidao -Parse the matrícula of a certidão de registro civil into its fields, returning `null` when the matrícula is not valid, which includes a book code that is not one of the nine books. [Art. 473, V of the Código Nacional de Normas da Corregedoria Nacional de Justiça](https://atos.cnj.jus.br/atos/detalhar/5243) lists the codes 1 to 7; the codes 8 (emancipação) and 9 (interdição) come from Anexo IV of the revoked Provimento CNJ nº 63/2017, as listed by [ghiorzi.org](http://ghiorzi.org/DVnew.htm), and are kept because matrículas issued under it are still in circulation. Only a string is accepted: the 32 digits of a matrícula are more than a JavaScript number can hold. +Remove the formatting of the matrícula of a certidão de registro civil, keep only digits, and cap the result to 32 digits. This only takes the mask off: use `isValidCertidao` to check the matrícula and `getCertidaoInfo` to read its fields. ```javascript import { parseCertidao } from '@brazilian-utils/brazilian-utils'; parseCertidao('104539 01 55 2013 1 00012 021 0000123 21'); +// '10453901552013100012021000012321' +``` + +### getCertidaoInfo + +Parse the matrícula of a certidão de registro civil into its fields, returning `null` when the matrícula is not valid, which includes a book code that is not one of the nine books. A serviço other than the `55` that art. 473, III fixes for the registro civil das pessoas naturais also gives `null`. [Art. 473, V of the Código Nacional de Normas da Corregedoria Nacional de Justiça](https://atos.cnj.jus.br/atos/detalhar/5243) lists the codes 1 to 7; no CNJ primary text reachable today publishes the other two, the Anexo IV of the revoked Provimento CNJ nº 63/2017 included, which lists the same seven. The codes 8 (emancipação) and 9 (interdição) come from the references the check digit rule rests on: [ghiorzi.org](http://ghiorzi.org/DVnew.htm) and [validation-br](https://github.com/klawdyo/validation-br/blob/feat-certidao/src/certidao.ts) both print the nine book list. They are kept because matrículas carrying them circulate. Only a string is accepted: the 32 digits of a matrícula are more than a JavaScript number can hold. + +```javascript +import { getCertidaoInfo } from '@brazilian-utils/brazilian-utils'; + +getCertidaoInfo('104539 01 55 2013 1 00012 021 0000123 21'); // { // registryCns: '104539', // acervo: '01', @@ -1814,15 +2045,15 @@ parseCertidao('104539 01 55 2013 1 00012 021 0000123 21'); // checkDigits: '21' // } -parseCertidao('invalid'); // null +getCertidaoInfo('invalid'); // null ``` -The `Certidao` result carries: +The `CertidaoInfo` result carries: | Key | Description | | --- | --- | | `registryCns` | The 6 digit CNS (Código Nacional de Serventia) of the serventia that issued the act. | -| `acervo` | Acervo the book belongs to: `"01"` the serventia's own, `"02"` a collection it absorbed. | +| `acervo` | Acervo the book belongs to: `"01"` the serventia's own, `"02"` and up one per acervo it absorbed. [Art. 473, §§ 3º to 5º](https://atos.cnj.jus.br/atos/detalhar/5243) splits the absorbed ones by the date the origin serventia was extinguished or deactivated: up to 31/12/2009 the matrícula carries the CNS of the incorporating unit and an acervo code from `"02"` up, one per incorporation; from 01/01/2010 on it carries the CNS of the incorporated unit itself and the code `"01"`, counted as that unit's own acervo; and an acervo split between two or more successor serventias gets each successor's own CNS with the code `"02"`. | | `service` | Service rendered by the serventia, always `"55"`, the registro civil das pessoas naturais. | | `year` | Four digit year the act was recorded. | | `type` | The book the act belongs to: `"birth"`, `"marriage"`, `"religious-marriage"`, `"death"`, `"stillbirth"`, `"banns"`, `"other"`, `"emancipation"` or `"interdiction"`. | @@ -1832,21 +2063,9 @@ The `Certidao` result carries: | `term` | The 7 digit term (termo) number, zero padded. | | `checkDigits` | The 2 modulus 11 check digits of the matrícula. | -### formatCertidao - -Format the matrícula of a certidão de registro civil into the printed mask of the Provimento, the 32 digits grouped as 6 2 2 4 1 5 3 7 2 and separated by spaces. `options.pad` (part of `FormatCertidaoOptions`) left pads the value with zeros up to 32 digits. The mask is the one of [art. 473 of the Código Nacional de Normas da Corregedoria Nacional de Justiça](https://atos.cnj.jus.br/atos/detalhar/5243). Only a string is accepted: the 32 digits of a matrícula are more than a JavaScript number can hold. - -```javascript -import { formatCertidao } from '@brazilian-utils/brazilian-utils'; - -formatCertidao('10453901552013100012021000012321'); // 104539 01 55 2013 1 00012 021 0000123 21 -formatCertidao('104539.01.55.2013.1.00012.021.0000123-21'); // 104539 01 55 2013 1 00012 021 0000123 21 -formatCertidao('1552010100020112000012087', { pad: true }); // 000000 01 55 2010 1 00020 112 0000120 87 -``` - ### isValidCei -Check if a CEI (Cadastro Específico do INSS) number is valid. The CEI identifies an employer with no CNPJ, such as a construction work or a rural producer: 12 digits printed as `00.000.00000/00`, the last one a check digit calculated over the 11 base digits with the weights 7, 4, 1, 8, 5, 2, 1, 6, 3, 7 and 4. Accepts the usual mask characters and whitespace between/around groups. The Receita Federal does not publish this check digit rule, so it follows the reference implementations of [yii2-br-validator](https://github.com/yiibr/yii2-br-validator/blob/master/src/CeiValidator.php) and [Bigai.Documentos.Brasil](https://github.com/marcos-cruz/Documento/blob/master/src/Bigai.Documentos.Brasil/Cei/Cei.cs), cross-checked against the [Cadastro Nacional de Obras (CNO) open dataset](https://dados.gov.br/dados/conjuntos-dados/cadastro-nacional-de-obras-cno) of the Receita Federal. +Check if a CEI (Cadastro Específico do INSS) number is valid. The CEI identifies an employer with no CNPJ, such as a construction work or a rural producer: 12 digits printed as `00.000.00000/00`, the last one a check digit calculated over the 11 base digits with the weights 7, 4, 1, 8, 5, 2, 1, 6, 3, 7 and 4. Accepts the usual mask characters and whitespace between/around groups, a run of them between two groups included. The Receita Federal does not publish this check digit rule, so it follows the reference implementations of [yii2-br-validator](https://github.com/yiibr/yii2-br-validator/blob/master/src/CeiValidator.php) and [Bigai.Documentos.Brasil](https://github.com/marcos-cruz/Documento/blob/master/src/Bigai.Documentos.Brasil/Cei/Cei.cs), cross-checked against the [Cadastro Nacional de Obras (CNO) open dataset](https://dados.gov.br/dados/conjuntos-dados/cadastro-nacional-de-obras-cno) of the Receita Federal. ```javascript import { isValidCei } from '@brazilian-utils/brazilian-utils'; @@ -1860,7 +2079,7 @@ isValidCei('000000000000'); // false (repeated digits) ### formatCei -Format a CEI (Cadastro Específico do INSS) number according to the usual `00.000.00000/00` mask, the one the reference implementations of the check digit agree on (the Receita Federal does not print it). Formats progressively, as far as the digits given go, so it can also be used as an input mask. `options.pad` (part of `FormatCeiOptions`) left pads the value with zeros up to 12 digits. +Format a CEI (Cadastro Específico do INSS) number according to the usual `00.000.00000/00` mask, the one the reference implementations of the check digit agree on (the Receita Federal does not print it). Formats progressively, as far as the digits given go, so it can also be used as an input mask. `options.pad` (part of `FormatCeiOptions`) left pads the value with zeros up to 12 digits (default `false`). ```javascript import { formatCei } from '@brazilian-utils/brazilian-utils'; @@ -1870,9 +2089,19 @@ formatCei(249859674386); // 24.985.96743/86 formatCei('249', { pad: true }); // 00.000.00002/49 ``` +### parseCei + +Remove CEI (Cadastro Específico do INSS) formatting, keep only digits, and cap the result to 12 digits. A partial value passes through as far as it goes; use `isValidCei` to check the number itself. + +```javascript +import { parseCei } from '@brazilian-utils/brazilian-utils'; + +parseCei('27.729.71181/87'); // '277297118187' +``` + ### isValidCno -Check if a CNO (Cadastro Nacional de Obras) number is valid. The CNO replaced the CEI for construction works and kept its numbering, so a work registered under a legacy CEI keeps the same number and both registries validate identically: 12 digits printed as `00.000.00000/00` with a check digit calculated over the 11 base digits. The Receita Federal does not publish the check digit rule; it was confirmed against the [Cadastro Nacional de Obras (CNO) open dataset](https://dados.gov.br/dados/conjuntos-dados/cadastro-nacional-de-obras-cno) of the Receita Federal: every one of the 38432 works registered in Minas Gerais passes this check. +Check if a CNO (Cadastro Nacional de Obras) number is valid. The CNO replaced the CEI for construction works and kept its numbering, so a work registered under a legacy CEI keeps the same number and both registries validate identically: 12 digits printed as `00.000.00000/00` with a check digit calculated over the 11 base digits. The Receita Federal does not publish the check digit rule; it was confirmed against the [Cadastro Nacional de Obras (CNO) open dataset](https://dados.gov.br/dados/conjuntos-dados/cadastro-nacional-de-obras-cno) of the Receita Federal: every work in the Minas Gerais extract of that dataset passes this check. The catalogue page itself publishes only the dataset's description and download links, not that result. ```javascript import { isValidCno } from '@brazilian-utils/brazilian-utils'; @@ -1886,7 +2115,7 @@ isValidCno('000000000000'); // false (repeated digits) ### formatCno -Format a CNO (Cadastro Nacional de Obras) number. The CNO kept the CEI's numbering, so both share the same 12 digit, `00.000.00000/00` mask, the one the reference implementations of the check digit agree on (the Receita Federal does not print it). Formats progressively, as far as the digits given go, so it can also be used as an input mask. `options.pad` (part of `FormatCnoOptions`) left pads the value with zeros up to 12 digits. +Format a CNO (Cadastro Nacional de Obras) number. The CNO kept the CEI's numbering, so both share the same 12 digit, `00.000.00000/00` mask, the one the reference implementations of the check digit agree on (the Receita Federal does not print it). Formats progressively, as far as the digits given go, so it can also be used as an input mask. `options.pad` (part of `FormatCnoOptions`) left pads the value with zeros up to 12 digits (default `false`). ```javascript import { formatCno } from '@brazilian-utils/brazilian-utils'; @@ -1896,9 +2125,19 @@ formatCno(401800097960); // 40.180.00979/60 formatCno('979', { pad: true }); // 00.000.00009/79 ``` +### parseCno + +Remove CNO (Cadastro Nacional de Obras) formatting, keep only digits, and cap the result to 12 digits, the numbering the CNO kept from the CEI. A shorter value passes through as far as it goes; use `isValidCno` to check the number itself. + +```javascript +import { parseCno } from '@brazilian-utils/brazilian-utils'; + +parseCno('11.113.01373/68'); // '111130137368' +``` + ### isValidCaepf -Check if a CAEPF (Cadastro de Atividade Econômica da Pessoa Física) number is valid. The CAEPF replaced the CEI for individuals who hire employees: 14 digits printed as `000.000.000/000-00`, formed by the 9 digit CPF base of the holder, a 3 digit sequence for the holder's several registrations and 2 check digits. Both check digits use the modulus 11 of the CNPJ, and the resulting pair is then shifted by 12, wrapping around 100. The Receita Federal does not publish the layout or the check digit rule: both are described by [ghiorzi.org](http://ghiorzi.org/DVnew.htm) and implemented the same way by [brazilian-values](https://github.com/VitorLuizC/brazilian-values/blob/master/src/validators/isCAEPF.ts). +Check if a CAEPF (Cadastro de Atividade Econômica da Pessoa Física) number is valid. The CAEPF replaced the CEI for individuals who hire employees: 14 digits printed as `000.000.000/000-00`, formed by the 9 digit CPF base of the holder, a 3 digit sequence for the holder's several registrations and 2 check digits. Both check digits are the CNPJ's modulus 11 in the formulation of the cited reference: the weights cycle from 9 down to 2 from the right and the check digit is the remainder itself, with a remainder of 10 read as 0 — the same digit the CNPJ's 2-to-9 weights with `11 - remainder` produce. The resulting pair is then shifted by 12, wrapping around 100. A base whose 12 digits are all the same is rejected before the check digits are computed, the way `isValidCei` and `isValidCno` reject a repeated CEI/CNO number, so the otherwise well-formed `00000000000012` is invalid. The Receita Federal does not publish the layout or the check digit rule: both are described by [ghiorzi.org](http://ghiorzi.org/DVnew.htm) and implemented the same way by [brazilian-values](https://github.com/VitorLuizC/brazilian-values/blob/master/src/validators/isCAEPF.ts). ```javascript import { isValidCaepf } from '@brazilian-utils/brazilian-utils'; @@ -1907,12 +2146,13 @@ isValidCaepf('293.118.610/001-84'); // true isValidCaepf('41142260000101'); // true isValidCaepf(29311861000184); // true isValidCaepf('29311861000185'); // false (invalid check digits) -isValidCaepf('00000000000000'); // false (repeated digits) +isValidCaepf('00000000000000'); // false (repeated base digits) +isValidCaepf('00000000000012'); // false (repeated base digits) ``` ### formatCaepf -Format a CAEPF (Cadastro de Atividade Econômica da Pessoa Física) number according to the usual `000.000.000/000-00` mask, the one the sources of the check digit rule agree on (the Receita Federal does not print it). Formats progressively, as far as the digits given go, so it can also be used as an input mask. `options.pad` (part of `FormatCaepfOptions`) left pads the value with zeros up to 14 digits. +Format a CAEPF (Cadastro de Atividade Econômica da Pessoa Física) number according to the usual `000.000.000/000-00` mask, the one the sources of the check digit rule agree on (the Receita Federal does not print it). Formats progressively, as far as the digits given go, so it can also be used as an input mask. `options.pad` (part of `FormatCaepfOptions`) left pads the value with zeros up to 14 digits (default `false`). ```javascript import { formatCaepf } from '@brazilian-utils/brazilian-utils'; @@ -1922,22 +2162,34 @@ formatCaepf(41142260000101); // 411.422.600/001-01 formatCaepf('184', { pad: true }); // 000.000.000/001-84 ``` +### parseCaepf + +Remove CAEPF (Cadastro de Atividade Econômica da Pessoa Física) formatting, keep only digits, and cap the result to 14 digits. A shorter value passes through as far as it goes; use `isValidCaepf` to check the number itself. + +```javascript +import { parseCaepf } from '@brazilian-utils/brazilian-utils'; + +parseCaepf('293.118.610/001-84'); // '29311861000184' +``` + ### isValidRegistroProfissional -Check the structure of a professional council registration number (registro/inscrição profissional). Options are typed as `IsValidRegistroProfissionalOptions`: `options.council` picks the issuing council (`"OAB"`, `"CRM"`, `"CRO"`, `"CRP"` or `"CRC"`) and the optional `options.stateCode` checks the embedded UF (ignored for `"CRP"`, whose 2 digit prefix is a regional code, not a literal UF). This is a structural check only: digit counts and the UF are validated, but no check digit is computed, even for CRC, whose format includes one. A CRC registration is the UF, 6 digits and the tipo de registro (`"O"` Originário, `"P"` Provisório or `"T"` Transferido, which says nothing about the professional category), as published in the [Manual de Registro do Sistema CFC/CRCs](https://cfc.org.br/wp-content/uploads/2018/04/1_manual_registro.pdf) (item 1.1) and in the Resolução CFC nº 1.707/2023. A CRP regional code has to be one of the [24 Conselhos Regionais](https://site.cfp.org.br/cfp/sistema-conselhos/conselhos-pelo-brasil/) of the CFP system, CRP-01 to CRP-24. The OAB, the CFM and the CFO publish no format for the numbers they issue, so the digit ranges accepted for `"OAB"`, `"CRM"` and `"CRO"` are conventional rather than normative. CREA is not supported: its registration format could not be confirmed from an official, publicly documented source after the 2016 national unification (RNP). +Check the structure of a professional council registration number (registro/inscrição profissional). It takes a single object, typed as `IsValidRegistroProfissionalOptions`, the shape `isValidBankAccount` takes: `value` is the registration number, `council` picks the issuing council (`"OAB"`, `"CRM"`, `"CRO"`, `"CRP"` or `"CRC"`) and the optional `stateCode` checks the embedded UF (ignored for `"CRP"`, whose 2 digit prefix is a regional code, not a literal UF). Anything that is not an object, and an object missing `value` or `council`, is `false`. The accepted shapes are 4 to 6 digits plus the UF for `"OAB"` and `"CRM"`, 3 to 6 digits plus the UF for `"CRO"`, a 2 digit regional code plus 4 to 6 digits for `"CRP"`, and the UF plus 6 digits, the tipo de registro and one check digit for `"CRC"`. This is a structural check only: digit counts and the UF are validated, but no check digit is computed, even for CRC, whose format includes one. A CRC registration is the UF, 6 digits, the tipo de registro (`"O"` Originário or `"P"` Provisório, which says nothing about the professional category) and the check digit, as published in the [Manual de Registro do Sistema CFC/CRCs](https://cfc.org.br/wp-content/uploads/2018/04/1_manual_registro.pdf) (item 1.1). A Registro Transferido or Secundário appends `"T"` or `"S"` and the UF of the destination CRC **after** the check digit, per that same item and [Resolução CFC nº 1.707/2023](https://www1.cfc.org.br/sisweb/SRE/docs/Res_1707.pdf), art. 5º parágrafo único: the Manual's own examples are `SP-123456/O-3 T-MG`, `TO-654321/P-8 T-SC` and `PI-111222/O-5 S-AC`. Both UFs must be real state codes, and `stateCode` is compared against the originating one. A CRP regional code has to be one of the [24 Conselhos Regionais](https://site.cfp.org.br/cfp/sistema-conselhos/conselhos-pelo-brasil/) of the CFP system, CRP-01 to CRP-24. Only the CRC shape and those CRP regional codes rest on a published source: the CFP page publishes no length for the inscription number itself, and the OAB, the CFM and the CFO publish no format at all, so the digit ranges accepted for `"CRP"`, `"OAB"`, `"CRM"` and `"CRO"` are conventional rather than normative (the OAB/SP public search field is `maxlength="7"`, and the CFM documents `300`-prefixed and `P`-suffixed CRMs, none of which these shapes express). CREA is not supported: its registration format could not be confirmed from an official, publicly documented source after the 2016 national unification (RNP). ```javascript import { isValidRegistroProfissional } from '@brazilian-utils/brazilian-utils'; -isValidRegistroProfissional('123456/SP', { council: 'OAB' }); // true -isValidRegistroProfissional('123456-RJ', { council: 'OAB', stateCode: 'SP' }); // false (UF mismatch) -isValidRegistroProfissional('06/12345', { council: 'CRP' }); // true -isValidRegistroProfissional('SP-123456/O-3', { council: 'CRC' }); // true +isValidRegistroProfissional({ value: '123456/SP', council: 'OAB' }); // true +isValidRegistroProfissional({ value: '123456-RJ', council: 'OAB', stateCode: 'SP' }); // false (UF mismatch) +isValidRegistroProfissional({ value: '06/12345', council: 'CRP' }); // true +isValidRegistroProfissional({ value: 'SP-123456/O-3', council: 'CRC' }); // true +isValidRegistroProfissional({ value: 'SP-123456/O-3 T-MG', council: 'CRC' }); // true (registro transferido) +isValidRegistroProfissional({ value: 'SP-123456/T-3', council: 'CRC' }); // false ("T" is not a tipo de registro) ``` ### isValidVin -Check if a VIN (Vehicle Identification Number / chassi) is valid. Checks the length (17 characters), the excluded letters (`I`, `O`, `Q` are never valid; [ISO 3779:2009](https://www.iso.org/standard/52200.html) structure) and the check digit at the 9th position, with the check digit and transliteration computed per [49 CFR 565.15](https://www.ecfr.gov/current/title-49/section-565.15). That check digit is a North-American requirement (49 CFR 565.15 / SAE J853): Resolução CONTRAN nº 24/1998 and ABNT NBR 6066 define the Brazilian VIN structure but do not mandate it, so many Brazilian-built VINs do not carry a matching check digit. This function is therefore a North-American-style structural check, not a universal validator of Brazilian VINs. Case-insensitive and trims surrounding whitespace. +Check if a VIN (Vehicle Identification Number / chassi) is valid. Checks the length (17 characters), the excluded letters (`I`, `O`, `Q` are never valid; [ISO 3779:2009](https://www.iso.org/standard/52200.html) structure) and the check digit at the 9th position, with the check digit and transliteration computed per [49 CFR 565.15](https://www.ecfr.gov/current/title-49/section-565.15). That check digit is a North-American requirement (49 CFR 565.15 / SAE J853): [Resolução CONTRAN nº 968/2022](https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes/resolucao9682022.pdf) (which revoked Resolução CONTRAN nº 24/1998 from 1 January 2025) and ABNT NBR 6066 define the Brazilian VIN structure but do not mandate it, so many Brazilian-built VINs do not carry a matching check digit. This function is therefore a North-American-style structural check, not a universal validator of Brazilian VINs. Case-insensitive and trims surrounding whitespace. A VIN is printed as one unbroken run of 17 characters, so, unlike the documents this package masks (`isValidCpf`, `isValidCnpj`, `isValidNfeKey`), it has no group boundary to write a separator at and none is accepted: a space, `.`, `-` or `/` among the characters is rejected instead of being stripped. A value whose 17 characters are all the same (`'00000000000000000'`) is rejected even when it carries a matching check digit, the way every other validator of this package rejects a repeated-digit document. ```javascript import { isValidVin } from '@brazilian-utils/brazilian-utils'; @@ -1945,12 +2197,13 @@ import { isValidVin } from '@brazilian-utils/brazilian-utils'; isValidVin('1HGCM82633A004352'); // true isValidVin('1m8gdm9axkp042788'); // true (check digit X, lowercase) isValidVin('1HGCM82633A004353'); // false (bad check digit) +isValidVin('00000000000000000'); // false (every character the same, though the check digit matches) isValidVin('1HGCM8263IA004352'); // false (contains the excluded letter I) ``` ### isValidCbo -Check if a CBO (Classificação Brasileira de Ocupações) code exists in the MTE occupation table. Accepts the code with or without the hyphen mask, or as a number. A string is only read as a code when it is written in one of those forms (the 6 digits, or the `NNNN-NN` mask, with the usual separators between the groups and optional surrounding whitespace), and a number only when it is a non-negative safe integer. +Check if a CBO (Classificação Brasileira de Ocupações) code exists in the MTE occupation table. Accepts the code with or without the hyphen mask, or as a number. A string is only read as a code when it is written in one of those forms (the 6 digits, or the `NNNN-NN` mask, with a single separator between the groups and optional surrounding whitespace), and a number only when it is a non-negative safe integer. A CBO code is always 6 digits and its leading zeros are part of it, so a value written as bare digits is left padded with zeros to 6 whether it comes as a string or as a number, exactly like `getBankByCode` pads a bank code: `10205`, `'10205'` and `'010205'` are the same code. A masked value already carries its separators and is read as written. ```javascript import { isValidCbo } from '@brazilian-utils/brazilian-utils'; @@ -1958,36 +2211,52 @@ import { isValidCbo } from '@brazilian-utils/brazilian-utils'; isValidCbo('2124-05'); // true isValidCbo('212405'); // true isValidCbo(212405); // true +isValidCbo(10205); // true (padded to 6 digits, so this is '010205') +isValidCbo('10205'); // true (padded the same way a number is) isValidCbo('000000'); // false isValidCbo('2124abc05'); // false (not a documented form) isValidCbo(-212405); // false (not a non-negative safe integer) ``` -The occupation titles come from the [official CBO 2002 tables published by the MTE](http://www.mtecbo.gov.br/cbosite/pages/downloads.jsf). +The occupation titles come from the [official CBO 2002 occupation table published by the MTE](https://www.gov.br/trabalho-e-emprego/pt-br/assuntos/cbo/servicos/downloads/cbo2002-ocupacao.csv). + +### parseCbo + +Remove CBO (Classificação Brasileira de Ocupações) formatting, keep only digits, and cap the result to 6 digits. A shorter value passes through as far as it goes and nothing is left padded here, so the leading zero of a code such as `010205` has to be written out; use `getCbo` or `isValidCbo`, which do pad a bare numeric code, to look an occupation up. + +```javascript +import { parseCbo } from '@brazilian-utils/brazilian-utils'; + +parseCbo('2124-05'); // '212405' +``` ### getCbo -Look a CBO (Classificação Brasileira de Ocupações) code up and get its official occupation title. A `number` keeps its implied leading zeros: `getCbo(10205)` is read as `010205`. Same input rules as `isValidCbo`: a string has to be written as the 6 digits or with the `NNNN-NN` mask, and a number has to be a non-negative safe integer. +Look a CBO (Classificação Brasileira de Ocupações) code up and get its official occupation title, in the `{ code, description }` record every lookup of this library returns. A value written as bare digits keeps its implied leading zeros, as a string as much as a number: `getCbo(10205)` and `getCbo('10205')` are both read as `010205`. Same input rules as `isValidCbo`: a string has to be written as the 6 digits or with the `NNNN-NN` mask, and a number has to be a non-negative safe integer. ```javascript import { getCbo } from '@brazilian-utils/brazilian-utils'; -getCbo('2124-05'); // { code: '212405', title: 'Analista de desenvolvimento de sistemas' } +getCbo('2124-05'); // { code: '212405', description: 'Analista de desenvolvimento de sistemas' } +getCbo(10205); // { code: '010205', description: 'Oficial da aeronáutica' } (padded to 6 digits) +getCbo('10205'); // { code: '010205', description: 'Oficial da aeronáutica' } (padded the same way) getCbo('000000'); // null getCbo('2124abc05'); // null (not a documented form) ``` -The occupation titles come from the [official CBO 2002 tables published by the MTE](http://www.mtecbo.gov.br/cbosite/pages/downloads.jsf). +The occupation titles come from the [official CBO 2002 occupation table published by the MTE](https://www.gov.br/trabalho-e-emprego/pt-br/assuntos/cbo/servicos/downloads/cbo2002-ocupacao.csv). ### isValidCnae -Check if a CNAE (Classificação Nacional de Atividades Econômicas) subclass code exists in the CNAE 2.3 table published by IBGE. Accepts the code with or without the `NNNN-N/NN` mask, or as a number. A string is only read as a code when it is written in one of those forms (the 7 digits, or the mask, with the usual separators between the groups and optional surrounding whitespace), and a number only when it is a non-negative safe integer. +Check if a CNAE (Classificação Nacional de Atividades Econômicas) subclass code exists in the [CNAE-Subclasses 2.3 table published by IBGE](https://concla.ibge.gov.br/busca-online-cnae.html), the current subclass revision of CNAE 2.0. Accepts the code with or without the `NNNN-N/NN` mask, or as a number. A string is only read as a code when it is written in one of those forms (the 7 digits, or the mask, with a single separator between the groups and optional surrounding whitespace), and a number only when it is a non-negative safe integer. A CNAE subclass code is always 7 digits and its leading zeros are part of it, so a value written as bare digits is left padded with zeros to 7 whether it comes as a string or as a number: `111301`, `'111301'` and `'0111301'` are the same code. A masked value already carries its separators and is read as written. ```javascript import { isValidCnae } from '@brazilian-utils/brazilian-utils'; isValidCnae('6201-5/01'); // true isValidCnae('6201501'); // true +isValidCnae(111301); // true (padded to 7 digits, so this is '0111301') +isValidCnae('111301'); // true (padded the same way a number is) isValidCnae('0000000'); // false isValidCnae('0111abc301'); // false (not a documented form) isValidCnae(-111301); // false (not a non-negative safe integer) @@ -1995,70 +2264,128 @@ isValidCnae(-111301); // false (not a non-negative safe integer) ### formatCnae -Format a CNAE (Classificação Nacional de Atividades Econômicas) subclass code. +Format a CNAE (Classificação Nacional de Atividades Econômicas) subclass code. `options.pad` (part of `FormatCnaeOptions`) works exactly like it does in `formatCpf`/`formatCep`: with the default `false` the mask is applied progressively, as far as the value goes, which is what an input being typed into needs; with `true` the value is first left padded with zeros to the 7 digits of a complete subclass code, so it always comes back fully masked. A number is treated exactly like the string of its digits, so it is only padded under `pad: true`. Like every formatter of this package, the value is read for its digits and masked as far as they go: characters outside the mask are dropped and a number is read as the string of its digits, sign and decimal point included. Use `isValidCnae` to check a code. ```javascript import { formatCnae } from '@brazilian-utils/brazilian-utils'; formatCnae('6201501'); // 6201-5/01 +formatCnae('62'); // 62 (masked as far as it goes) +formatCnae('62015'); // 6201-5 +formatCnae('62', { pad: true }); // 0000-0/62 (padded to 7 digits first) +formatCnae(111301, { pad: true }); // 0111-3/01 +formatCnae('abc6201501'); // 6201-5/01 (only the digits are read) +formatCnae(-6201501); // 6201-5/01 +``` + +### parseCnae + +Remove CNAE (Classificação Nacional de Atividades Econômicas) formatting, keep only digits, and cap the result to the 7 digits of a complete subclass code. Nothing is left padded here; use `getCnae` or `isValidCnae`, which do pad a bare numeric code, to look a subclass up. + +```javascript +import { parseCnae } from '@brazilian-utils/brazilian-utils'; + +parseCnae('6201-5/01'); // '6201501' +parseCnae('62'); // '62' (a partial code is kept as written) ``` ### getCnae -Look a CNAE (Classificação Nacional de Atividades Econômicas) subclass code up and get its formatted code and official description. A `number` keeps its implied leading zeros: `getCnae(111301)` is read as `0111301`. Same input rules as `isValidCnae`: a string has to be written as the 7 digits or with the `NNNN-N/NN` mask, and a number has to be a non-negative safe integer. +Look a CNAE (Classificação Nacional de Atividades Econômicas) subclass code up and get its code and official description. `code` comes back as the 7 bare digits, like every other lookup of this library; pass it to `formatCnae` for the `NNNN-N/NN` form. A value written as bare digits keeps its implied leading zeros, as a string as much as a number: `getCnae(111301)` and `getCnae('111301')` are both read as `0111301`. Same input rules as `isValidCnae`: a string has to be written as the 7 digits or with the `NNNN-N/NN` mask, and a number has to be a non-negative safe integer. ```javascript -import { getCnae } from '@brazilian-utils/brazilian-utils'; +import { formatCnae, getCnae } from '@brazilian-utils/brazilian-utils'; -getCnae('6201501'); // { code: '6201-5/01', description: 'DESENVOLVIMENTO DE PROGRAMAS DE COMPUTADOR SOB ENCOMENDA' } +getCnae('6201-5/01'); // { code: '6201501', description: 'DESENVOLVIMENTO DE PROGRAMAS DE COMPUTADOR SOB ENCOMENDA' } +getCnae(111301); // { code: '0111301', description: 'CULTIVO DE ARROZ' } (padded to 7 digits) +getCnae('111301'); // { code: '0111301', description: 'CULTIVO DE ARROZ' } (padded the same way) getCnae('0000000'); // null getCnae('0111abc301'); // null (not a documented form) +formatCnae(getCnae('6201501')?.code); // 6201-5/01 (the mask is the formatter's job) ``` ### isValidNcm -Check if an NCM (Nomenclatura Comum do Mercosul) code exists in the current table published by Siscomex/MDIC. Accepts the code with or without the dotted mask, or as a number. +Check if an NCM (Nomenclatura Comum do Mercosul) code exists in the current table published by Siscomex/MDIC. Accepts the code with or without the dotted mask, or as a number. A string is only read as a code when it is written in one of those forms (the 8 digits, or the `NNNN.NN.NN` mask, with a single separator between the groups and optional surrounding whitespace), and a number only when it is a non-negative safe integer. An NCM code is always 8 digits and its leading zeros are part of it, so a value written as bare digits is left padded with zeros to 8 whether it comes as a string or as a number: `1012100`, `'1012100'` and `'01012100'` are the same code. A masked value already carries its separators and is read as written. ```javascript import { isValidNcm } from '@brazilian-utils/brazilian-utils'; isValidNcm('8471.30.12'); // true isValidNcm('84713012'); // true +isValidNcm(1012100); // true (padded to 8 digits, so this is '01012100') +isValidNcm('1012100'); // true (padded the same way a number is) isValidNcm('00000000'); // false +isValidNcm('abc01012100'); // false (not a documented form) +isValidNcm(-84713012); // false (not a non-negative safe integer) ``` ### formatNcm -Format an NCM (Nomenclatura Comum do Mercosul) code. +Format an NCM (Nomenclatura Comum do Mercosul) code. `options.pad` (part of `FormatNcmOptions`) works exactly like it does in `formatCpf`/`formatCep`: with the default `false` the mask is applied progressively, as far as the value goes, which is what an input being typed into needs; with `true` the value is first left padded with zeros to the 8 digits of a complete code, so it always comes back fully masked. A number is treated exactly like the string of its digits, so it is only padded under `pad: true`. Like every formatter of this package, the value is read for its digits and masked as far as they go: characters outside the mask are dropped and a number is read as the string of its digits, sign and decimal point included. Use `isValidNcm` to check a code. ```javascript import { formatNcm } from '@brazilian-utils/brazilian-utils'; formatNcm('84713012'); // 8471.30.12 +formatNcm('8471'); // 8471 (masked as far as it goes) +formatNcm('847130'); // 8471.30 +formatNcm('8471', { pad: true }); // 0000.84.71 (padded to 8 digits first) +formatNcm('abc8471'); // 8471 (only the digits are read) +formatNcm(-84713012); // 8471.30.12 +``` + +### parseNcm + +Remove NCM (Nomenclatura Comum do Mercosul) formatting, keep only digits, and cap the result to the 8 digits of a complete code. Nothing is left padded here; use `isValidNcm`, which does pad a bare numeric code, to check a code against the official table. + +```javascript +import { parseNcm } from '@brazilian-utils/brazilian-utils'; + +parseNcm('8471.30.12'); // '84713012' +parseNcm('8471'); // '8471' (a partial code is kept as written) ``` ### isValidCfop -Check if a CFOP (Código Fiscal de Operações e Prestações) code exists in the official table (Ajuste SINIEF 07/2001 and updates). Only operable codes count: the group and subgroup headings of the official nomenclature, the codes ending in `00` and `50` (1000, 1100, 1150, 5350, ...), are section titles rather than codes a document can carry, so they are rejected. +Check if a CFOP (Código Fiscal de Operações e Prestações) code exists in the official table. The table is the [consolidated Anexo II of Convênio SINIEF s/nº 1970](https://www.confaz.fazenda.gov.br/legislacao/ajustes/sinief/cfop_cvsn_1-6.24), the text in force (current wording given by Ajuste SINIEF 03/24, last amended by [Ajuste SINIEF 39/25](https://www.confaz.fazenda.gov.br/legislacao/ajustes/2025/AJ039_25)), not the frozen 2001 text of Ajuste SINIEF 07/01. Only operable codes count: the group and subgroup headings of the official nomenclature, the codes ending in `00` and `50` (1000, 1100, 1150, 5350, ...), are section titles rather than codes a document can carry, so they are rejected. + +A string is only read as a code when it is written in one of the documented forms (the 4 digits, or the `N.NNN` form the annex prints, with a single separator between the groups and optional surrounding whitespace), and a number only when it is a non-negative safe integer. No CFOP code starts with a zero, its first digit is the operation group (1 to 7), so nothing is ever padded here: a number and the string of the same digits are read identically. ```javascript import { isValidCfop } from '@brazilian-utils/brazilian-utils'; isValidCfop('5102'); // true +isValidCfop('1.101'); // true +isValidCfop('7504'); // true (added by the 2022 rewrite of the annex) isValidCfop('0000'); // false isValidCfop('1150'); // false (a subgroup heading, not an operable code) +isValidCfop('abc5102'); // false (not a documented form) +isValidCfop(-5102); // false (not a non-negative safe integer) +``` + +### parseCfop + +Remove CFOP (Código Fiscal de Operações e Prestações) formatting, keep only digits, and cap the result to 4 digits. A shorter value passes through as far as it goes. No CFOP code starts with a zero, its first digit is the operation group from 1 to 7, so nothing is ever padded here. + +```javascript +import { parseCfop } from '@brazilian-utils/brazilian-utils'; + +parseCfop('5.102'); // '5102' ``` ### getCfop -Look a CFOP (Código Fiscal de Operações e Prestações) code up and get its code and official description. The group and subgroup headings of the official nomenclature, the codes ending in `00` and `50`, are not in the table and give `null`. +Look a CFOP (Código Fiscal de Operações e Prestações) code up and get its code and official description, as the [consolidated Anexo II of Convênio SINIEF s/nº 1970](https://www.confaz.fazenda.gov.br/legislacao/ajustes/sinief/cfop_cvsn_1-6.24) words it, in the text in force, last amended by [Ajuste SINIEF 39/25](https://www.confaz.fazenda.gov.br/legislacao/ajustes/2025/AJ039_25). The group and subgroup headings of the official nomenclature, the codes ending in `00` and `50`, are not in the table and give `null`. Same input rules as `isValidCfop`. ```javascript import { getCfop } from '@brazilian-utils/brazilian-utils'; -getCfop('5102'); // { code: '5102', description: 'Venda de mercadoria adquirida ou recebida de terceiros' } +getCfop('1101'); // { code: '1101', description: 'Compra para industrialização ou produção rural' } +getCfop('7504'); // { code: '7504', description: 'Exportação de mercadoria que foi objeto de formação de lote de exportação' } getCfop('0000'); // null getCfop('5350'); // null (a subgroup heading, not an operable code) +getCfop('abc5102'); // null (not a documented form) ``` ### isValidCst @@ -2067,33 +2394,49 @@ Check if a CST (Código de Situação Tributária) code is valid for a given tax | Tax | Format | Accepted codes | | --- | --- | --- | -| `icms` | 3 digits (origem + CST) | origem `0`-`8` + one of `00`, `10`, `20`, `30`, `40`, `41`, `50`, `51`, `60`, `70`, `90` | +| `icms` | 3 digits (origem + CST) | origem `0`-`8` + one of `00`, `02`, `10`, `15`, `20`, `30`, `40`, `41`, `50`, `51`, `53`, `60`, `61`, `70`, `90` | | `ipi` | 2 digits | `00`, `01`, `02`, `03`, `04`, `05`, `49`, `50`, `51`, `52`, `53`, `54`, `55`, `99` | | `pis` | 2 digits | `01`-`09`, `49`, `50`-`56`, `60`-`67`, `70`-`75`, `98`, `99` | | `cofins` | 2 digits | same table as `pis` | -`options.tax` (part of `IsValidCstOptions`) is optional: omit it to accept a code that exists in any one of the four tables above. +`options.tax` (part of `IsValidCstOptions`) is optional: omit it to accept a code that exists in any one of the four tables above. A `tax` outside those four values falls back to that same default at runtime, the way every other scalar option of this library treats a value it does not know. + +The ICMS Tabela B is the one in force: the [consolidated Anexo I of Convênio SINIEF s/nº 1970](https://www.confaz.fazenda.gov.br/legislacao/ajustes/sinief/cvsn_70), whose current wording came from [Ajuste SINIEF 39/23](https://www.confaz.fazenda.gov.br/legislacao/ajustes/2023/ajuste-sinief-39-23) (effective 01.12.23) and which [Ajuste SINIEF 20/24](https://www.confaz.fazenda.gov.br/legislacao/ajustes/2024/AJ020_24) amended by striking items 12, 13, 52, 72 and 74 (effects from 09.07.24) before they ever took effect: 39/23 had deferred their effect to 1º de outubro de 2024, so the revocation reached them first and those codes were never in force. `02`, `15`, `53` and `61` are its monofasia de combustíveis codes. + +A string is only read as a code when it is written in one of the documented forms (the 2 digits of a Tabela B code, or the 3 digits of the ICMS form with an optional single separator after the origin digit, plus optional surrounding whitespace), and a number only when it is a non-negative safe integer. The origin digit is the only boundary a printed CST has, so `'0 10'` and `'1-10'` are read while `'0-0'`, `'11-0'` and `'00-'` are not. + +A single digit is narrower than either documented form, so it is left padded with zeros to the 3 digits of the ICMS form, whether it comes as a string or as a number: `0`, `'0'` and `'000'` are all the ICMS code `000`. A 2 digit value is already a documented form, a Tabela B code, and is read as written, so a Tabela B code keeps its own two digits: `'07'`, not `7`, which is the ICMS code `007`. ```javascript import { isValidCst } from '@brazilian-utils/brazilian-utils'; isValidCst('000', { tax: 'icms' }); // true +isValidCst(0, { tax: 'icms' }); // true (a single digit is padded to the 3 digit form, '000') +isValidCst('0', { tax: 'icms' }); // true (padded the same way a number is) isValidCst('110', { tax: 'icms' }); // true +isValidCst('002', { tax: 'icms' }); // true (monofasia de combustíveis) isValidCst('06', { tax: 'pis' }); // true isValidCst('99', { tax: 'ipi' }); // true isValidCst('110'); // true (found in the icms table, tax omitted) +isValidCst('000', { tax: 'nope' }); // true (an unknown tax falls back to every table) isValidCst('999'); // false (not in any table) +isValidCst('abc110'); // false (not a documented form) +isValidCst(-110); // false (not a non-negative safe integer) ``` ### isValidCsosn -Check if a CSOSN (Código de Situação da Operação no Simples Nacional) code is one of the 10 codes defined by Ajuste SINIEF 03/2010: `101`, `102`, `103`, `201`, `202`, `203`, `300`, `400`, `500` or `900`. +Check if a CSOSN (Código de Situação da Operação no Simples Nacional) code is one of the 10 codes of the [consolidated Anexo III-A of Convênio SINIEF s/nº 1970](https://www.confaz.fazenda.gov.br/legislacao/ajustes/sinief/cvsn_70), the table Ajuste SINIEF 03/2010 instituted: `101`, `102`, `103`, `201`, `202`, `203`, `300`, `400`, `500` or `900`. + +A string is only read as a code when it is written as the bare 3 digits with optional surrounding whitespace: a CSOSN has no printed grouping (the NF-e carries the origin digit in its own `orig` field), so `'1-01'` is rejected; a number is read only when it is a non-negative safe integer. No CSOSN code starts with a zero, the table runs from `101` to `900`, so nothing is ever padded here: a number and the string of the same digits are read identically. ```javascript import { isValidCsosn } from '@brazilian-utils/brazilian-utils'; isValidCsosn('101'); // true isValidCsosn('999'); // false +isValidCsosn('abc101'); // false (not a documented form) +isValidCsosn(-101); // false (not a non-negative safe integer) ``` ### removeAccents diff --git a/docs/llms.txt b/docs/llms.txt index c0834e7c..f12e2abc 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -10,7 +10,7 @@ Install with `npm install --save @brazilian-utils/brazilian-utils` (also availab import { isValidCpf } from '@brazilian-utils/brazilian-utils'; ``` -Every util is also available as its own subpath for lazy-loading/code-splitting, `@brazilian-utils/brazilian-utils/` (kebab-case of the function name, e.g. `isValidCpf` maps to `is-valid-cpf`) - most useful for `getCities`, the one util that embeds a large dataset: +Every util is also available as its own subpath for lazy-loading/code-splitting, `@brazilian-utils/brazilian-utils/` (kebab-case of the function name, e.g. `isValidCpf` maps to `is-valid-cpf`) - most useful for the utils that embed an official dataset (`getMunicipalities`, `getMunicipalityByCode`, `getMunicipality`, `getCities`, `isValidNcm`, `isValidCbo`, `getCbo`, `isValidCnae`, `getCnae`, `isValidCfop`, `getCfop`, `getBanks` and `getBankByCode`): ```javascript const { getCities } = await import('@brazilian-utils/brazilian-utils/get-cities'); @@ -20,7 +20,7 @@ const { getCities } = await import('@brazilian-utils/brazilian-utils/get-cities' - [Getting started](https://brazilian-utils.com.br/getting-started.md): installation, runtime support, usage and bundle size/subpath imports - [Utilities](https://brazilian-utils.com.br/utilities.md): full English reference, one section per function, with signatures and examples -- [Bundle size](https://brazilian-utils.com.br/getting-started.md#bundle-size): tree-shaking behavior and the `getCities`/subpath-import exception +- [Bundle size](https://brazilian-utils.com.br/getting-started.md#bundle-size): tree-shaking behavior and the dataset-backed utils that are worth a subpath import ## Validators (isValid*) @@ -29,20 +29,20 @@ const { getCities } = await import('@brazilian-utils/brazilian-utils/get-cities' - [isValidCep](https://brazilian-utils.com.br/utilities.md#isvalidcep): Check if CEP (brazilian postal code) is valid. - [isValidBoleto](https://brazilian-utils.com.br/utilities.md#isvalidboleto): Check if boleto (brazilian payment method) is valid. - [isValidPixKey](https://brazilian-utils.com.br/utilities.md#isvalidpixkey): Check if a Pix key (chave Pix) is valid: a CPF, a CNPJ, an e-mail address, a Brazilian mobile phone number or a random key (EVP), per the DICT key formats. -- [isValidPixPayload](https://brazilian-utils.com.br/utilities.md#isvalidpixpayload): Check if a Pix BR Code payload (the string behind a Pix QR Code and behind "Pix copia e cola") is valid: well-formed TLV structure, the mandatory objects present, one of the "Merchant Account Information" templates carrying the `br.gov.bcb.pix` GUI with a key or a URL, a "Point of Initiation Method" object (`01`) that agrees with it (a key requires a static payload, so `01` is absent or `"11"`; a URL requires a dynamic one, so `01` is `"12"`), an amount (`54`) greater than zero in a static payload, and a matching CRC-16. +- [isValidPixPayload](https://brazilian-utils.com.br/utilities.md#isvalidpixpayload): Check if a Pix BR Code payload (the string behind a Pix QR Code and behind "Pix copia e cola") is valid: well-formed TLV structure, the mandatory objects present, one of the "Merchant Account Information" templates carrying the `br.gov.bcb.pix` GUI with a key or a URL, and a matching CRC-16. - [isValidNfeKey](https://brazilian-utils.com.br/utilities.md#isvalidnfekey): Check if a DF-e (Documento Fiscal eletrônico) access key (chave de acesso) is valid. - [isValidEmail](https://brazilian-utils.com.br/utilities.md#isvalidemail): Check if email is valid. - [isValidPhone](https://brazilian-utils.com.br/utilities.md#isvalidphone): Check if phone number (mobile or landline) is valid. - [isValidMobilePhone](https://brazilian-utils.com.br/utilities.md#isvalidmobilephone): Check if mobile phone number is valid. - [isValidLandlinePhone](https://brazilian-utils.com.br/utilities.md#isvalidlandlinephone): Check if landline phone number is valid. -- [isValidServicePhone](https://brazilian-utils.com.br/utilities.md#isvalidservicephone): Check if a phone number is a valid Brazilian service number, dialed without a DDD: the Códigos Não Geográficos `0300`, `0303`, `0500`, `0800` and `0900` (11 digits total), the abbreviated `300X`/`400X` numbers (8 digits), and the 3-digit Códigos de Acesso a Serviços de Utilidade Pública that Anatel has designated (e.g. `190`, `192`; `112` and `911` are accepted too, as mobile-only aliases of `190` that Anatel lists alongside the other 3-digit codes). +- [isValidServicePhone](https://brazilian-utils.com.br/utilities.md#isvalidservicephone): Check if a phone number is a valid Brazilian service number, dialed without a DDD: the Códigos Não Geográficos `0300`, `0303`, `0500`, `0800` and `0900` (11 digits total, so the shorter, extinct `0800` + 6 digit form is rejected), the abbreviated `300X`/`400X` numbers (8 digits), and the 3-digit Códigos de Acesso a Serviços de Utilidade Pública that Anatel has designated (e.g. `190`, `192`), whose consolidated table is the Anexo of Ato Anatel nº 43.151/2004. - [isValidLicensePlate](https://brazilian-utils.com.br/utilities.md#isvalidlicenseplate): Check if license plate is valid. - [isValidRenavam](https://brazilian-utils.com.br/utilities.md#isvalidrenavam): Check if RENAVAM (Registro Nacional de Veículos Automotores) is valid. - [isValidPis](https://brazilian-utils.com.br/utilities.md#isvalidpis): Check if PIS is valid. -- [isValidProcessoJuridico](https://brazilian-utils.com.br/utilities.md#isvalidprocessojuridico): Validate the processo jurídico number according to CNJ's definition. +- [isValidProcessoJuridico](https://brazilian-utils.com.br/utilities.md#isvalidprocessojuridico): Validate the processo jurídico number according to CNJ's definition: the `NNNNNNN-DD.AAAA.J.TR.OOOO` layout, the `DD` check digits and the `J`/`TR` pair, which must identify an existing órgão and tribunal from the closed lists defined by Resolução CNJ nº 65/2008, so a number carrying a correct check digit but a court that does not exist is rejected. - [isValidIe](https://brazilian-utils.com.br/utilities.md#isvalidie): Check if inscrição estadual (state registration) is valid. - [isValidBankAccount](https://brazilian-utils.com.br/utilities.md#isvalidbankaccount): Check if a Brazilian bank account is valid. -- [isValidIban](https://brazilian-utils.com.br/utilities.md#isvalidiban): Check if a Brazilian IBAN (International Bank Account Number) is valid, per Bacen's Diretrizes de Implementação do IBAN no Brasil (Circular BCB nº 3.625/2013): `BR` + 2 ISO 7064 MOD 97-10 check digits + 8 digit ISPB + 5 digit branch + 10 digit account + 1 letter account type (any letter, usually `C` for conta corrente or `P` for conta poupança) + 1 alphanumeric owner indicator, 29 characters total. +- [isValidIban](https://brazilian-utils.com.br/utilities.md#isvalidiban): Check if a Brazilian IBAN (International Bank Account Number) is valid, per Bacen's Diretrizes de Implementação do IBAN no Brasil (Circular BCB nº 3.625/2013): `BR` + 2 ISO 7064 MOD 97-10 check digits + 8 digit ISPB + 5 digit branch + 10 digit account + 1 letter account type (any letter, usually `C` for conta corrente or `P` for conta poupança) + 1 owner indicator (`1` for the first or only holder up to `9` for the ninth, then `A` to `Z` from the tenth, so `0` is rejected), 29 characters total. - [isValidCreditCard](https://brazilian-utils.com.br/utilities.md#isvalidcreditcard): Check if a payment card number is valid using the Luhn algorithm (ISO/IEC 7812-1). - [isValidPassport](https://brazilian-utils.com.br/utilities.md#isvalidpassport): Check if a Brazilian passport number is valid (2 letters followed by 6 digits). - [isValidCnh](https://brazilian-utils.com.br/utilities.md#isvalidcnh): Check if CNH is valid. @@ -56,23 +56,23 @@ const { getCities } = await import('@brazilian-utils/brazilian-utils/get-cities' - [isValidRegistroProfissional](https://brazilian-utils.com.br/utilities.md#isvalidregistroprofissional): Check the structure of a professional council registration number (registro/inscrição profissional). - [isValidVin](https://brazilian-utils.com.br/utilities.md#isvalidvin): Check if a VIN (Vehicle Identification Number / chassi) is valid. - [isValidCbo](https://brazilian-utils.com.br/utilities.md#isvalidcbo): Check if a CBO (Classificação Brasileira de Ocupações) code exists in the MTE occupation table. -- [isValidCnae](https://brazilian-utils.com.br/utilities.md#isvalidcnae): Check if a CNAE (Classificação Nacional de Atividades Econômicas) subclass code exists in the CNAE 2.3 table published by IBGE. +- [isValidCnae](https://brazilian-utils.com.br/utilities.md#isvalidcnae): Check if a CNAE (Classificação Nacional de Atividades Econômicas) subclass code exists in the CNAE-Subclasses 2.3 table published by IBGE, the current subclass revision of CNAE 2.0. - [isValidNcm](https://brazilian-utils.com.br/utilities.md#isvalidncm): Check if an NCM (Nomenclatura Comum do Mercosul) code exists in the current table published by Siscomex/MDIC. -- [isValidCfop](https://brazilian-utils.com.br/utilities.md#isvalidcfop): Check if a CFOP (Código Fiscal de Operações e Prestações) code exists in the official table (Ajuste SINIEF 07/2001 and updates). +- [isValidCfop](https://brazilian-utils.com.br/utilities.md#isvalidcfop): Check if a CFOP (Código Fiscal de Operações e Prestações) code exists in the official table. - [isValidCst](https://brazilian-utils.com.br/utilities.md#isvalidcst): Check if a CST (Código de Situação Tributária) code is valid for a given tax. -- [isValidCsosn](https://brazilian-utils.com.br/utilities.md#isvalidcsosn): Check if a CSOSN (Código de Situação da Operação no Simples Nacional) code is one of the 10 codes defined by Ajuste SINIEF 03/2010: `101`, `102`, `103`, `201`, `202`, `203`, `300`, `400`, `500` or `900`. +- [isValidCsosn](https://brazilian-utils.com.br/utilities.md#isvalidcsosn): Check if a CSOSN (Código de Situação da Operação no Simples Nacional) code is one of the 10 codes of the consolidated Anexo III-A of Convênio SINIEF s/nº 1970, the table Ajuste SINIEF 03/2010 instituted: `101`, `102`, `103`, `201`, `202`, `203`, `300`, `400`, `500` or `900`. ## Formatters (format*) - [formatCpf](https://brazilian-utils.com.br/utilities.md#formatcpf): Format CPF. - [formatCnpj](https://brazilian-utils.com.br/utilities.md#formatcnpj): Format CNPJ. - [formatBoleto](https://brazilian-utils.com.br/utilities.md#formatboleto): Format a boleto number. -- [formatNfeKey](https://brazilian-utils.com.br/utilities.md#formatnfekey): Format a DF-e (NF-e, NFC-e, CT-e, MDF-e or CT-e OS) access key into groups of 4 digits separated by spaces, the common display form printed on the DANFE. +- [formatNfeKey](https://brazilian-utils.com.br/utilities.md#formatnfekey): Format a DF-e (Documento Fiscal eletrônico) access key into groups of 4 digits separated by spaces, the form every auxiliary document prints it in: the DANFE of the NF-e and the NFC-e, the DACTE of the CT-e, the CT-e OS and the GTV-e, the DAMDFE of the MDF-e, the DABPE of the BP-e, the DANF3E of the NF3e and the DANFE-COM of the NFCom. - [formatPhone](https://brazilian-utils.com.br/utilities.md#formatphone): Format phone number according to Brazilian patterns. - [formatPis](https://brazilian-utils.com.br/utilities.md#formatpis): Format PIS number. - [formatCep](https://brazilian-utils.com.br/utilities.md#formatcep): Format CEP (brazilian postal code). - [formatProcessoJuridico](https://brazilian-utils.com.br/utilities.md#formatprocessojuridico): Format the processo jurídico number according to CNJ's definition (mask `NNNNNNN-DD.AAAA.J.TR.OOOO`). -- [formatIban](https://brazilian-utils.com.br/utilities.md#formatiban): Format a Brazilian IBAN by grouping it in blocks of 4 characters, the ISO 13616 "print" presentation used on statements and bank forms. +- [formatIban](https://brazilian-utils.com.br/utilities.md#formatiban): Format an IBAN in the ISO 13616 print grouping, blocks of 4 characters, the presentation used on statements and bank forms. - [formatCurrency](https://brazilian-utils.com.br/utilities.md#formatcurrency): Formats an integer or float to a string in the BRL pattern. - [formatPassport](https://brazilian-utils.com.br/utilities.md#formatpassport): Format a Brazilian passport number (uppercase, without symbols, capped to 8 characters). - [formatCnh](https://brazilian-utils.com.br/utilities.md#formatcnh): Format CNH. @@ -92,21 +92,27 @@ const { getCities } = await import('@brazilian-utils/brazilian-utils/get-cities' - [parseCpf](https://brazilian-utils.com.br/utilities.md#parsecpf): Remove CPF formatting, keep only digits, and cap the result to 11 digits. - [parseCnpj](https://brazilian-utils.com.br/utilities.md#parsecnpj): Remove CNPJ formatting, return a normalized value, and cap the result to 14 characters. - [parseBoleto](https://brazilian-utils.com.br/utilities.md#parseboleto): Remove boleto formatting, keep only digits, and cap the result to 47 digits (48 for boleto de arrecadação). -- [parsePixKey](https://brazilian-utils.com.br/utilities.md#parsepixkey): Identifies a Pix key and normalizes it to the canonical form the DICT expects inside a BR Code: 11 digit CPF, 14 character CNPJ, lowercased e-mail, E.164 mobile phone (a landline is not a Pix key) or lowercase UUID EVP. -- [parsePixPayload](https://brazilian-utils.com.br/utilities.md#parsepixpayload): Parses a Pix BR Code payload into its fields. -- [parseNfeKey](https://brazilian-utils.com.br/utilities.md#parsenfekey): Parses a DF-e access key into its fields (state, year, month, taxId, model, series, number, emissionType, code, checkDigit). +- [parseNfeKey](https://brazilian-utils.com.br/utilities.md#parsenfekey): Remove the formatting of a DF-e access key (chave de acesso), keep only digits, and cap the result to 44 digits. - [parsePhone](https://brazilian-utils.com.br/utilities.md#parsephone): Remove phone formatting, keep only digits, and cap the result to 11 digits. - [parsePis](https://brazilian-utils.com.br/utilities.md#parsepis): Remove PIS formatting, keep only digits, and cap the result to 11 digits. - [parseCep](https://brazilian-utils.com.br/utilities.md#parsecep): Remove CEP formatting, keep only digits, and cap the result to 8 digits. - [parseProcessoJuridico](https://brazilian-utils.com.br/utilities.md#parseprocessojuridico): Remove processo jurídico formatting, keep only digits, and cap the result to 20 digits. -- [parseIban](https://brazilian-utils.com.br/utilities.md#parseiban): Parses a Brazilian IBAN into its fields: 2 (country code, always `BR`) + 2 (ISO 7064 MOD 97-10 check digits) + 8 (ISPB) + 5 (branch) + 10 (account) + 1 (account type, any letter, usually `C` for conta corrente or `P` for conta poupança) + 1 (owner indicator). +- [parseIban](https://brazilian-utils.com.br/utilities.md#parseiban): Remove IBAN formatting, keep the letters and digits, uppercase the result, and cap it to the 29 characters of a Brazilian IBAN. - [parseCurrency](https://brazilian-utils.com.br/utilities.md#parsecurrency): Transforms a string to an integer or float format. - [parsePassport](https://brazilian-utils.com.br/utilities.md#parsepassport): Remove all non-alphanumeric characters from a passport number, uppercase the result, and cap it to 8 characters. - [parseCnh](https://brazilian-utils.com.br/utilities.md#parsecnh): Remove CNH formatting, keep only digits, and cap the result to 11 digits. - [parseLegalNature](https://brazilian-utils.com.br/utilities.md#parselegalnature): Remove legal nature formatting, keep only digits, and cap the result to 4 digits. - [parseLicensePlate](https://brazilian-utils.com.br/utilities.md#parselicenseplate): Remove separators from a license plate, normalize it to uppercase, and cap it to 7 characters. - [parseVoterId](https://brazilian-utils.com.br/utilities.md#parsevoterid): Remove voter ID formatting, keep only digits, and cap the result to 12 digits (13 when the UF digits identify São Paulo or Minas Gerais). -- [parseCertidao](https://brazilian-utils.com.br/utilities.md#parsecertidao): Parse the matrícula of a certidão de registro civil into its fields, returning `null` when the matrícula is not valid, which includes a book code that is not one of the nine books. +- [parseCns](https://brazilian-utils.com.br/utilities.md#parsecns): Remove CNS (Cartão Nacional de Saúde) formatting, keep only digits, and cap the result to 15 digits. +- [parseCertidao](https://brazilian-utils.com.br/utilities.md#parsecertidao): Remove the formatting of the matrícula of a certidão de registro civil, keep only digits, and cap the result to 32 digits. +- [parseCei](https://brazilian-utils.com.br/utilities.md#parsecei): Remove CEI (Cadastro Específico do INSS) formatting, keep only digits, and cap the result to 12 digits. +- [parseCno](https://brazilian-utils.com.br/utilities.md#parsecno): Remove CNO (Cadastro Nacional de Obras) formatting, keep only digits, and cap the result to 12 digits, the numbering the CNO kept from the CEI. +- [parseCaepf](https://brazilian-utils.com.br/utilities.md#parsecaepf): Remove CAEPF (Cadastro de Atividade Econômica da Pessoa Física) formatting, keep only digits, and cap the result to 14 digits. +- [parseCbo](https://brazilian-utils.com.br/utilities.md#parsecbo): Remove CBO (Classificação Brasileira de Ocupações) formatting, keep only digits, and cap the result to 6 digits. +- [parseCnae](https://brazilian-utils.com.br/utilities.md#parsecnae): Remove CNAE (Classificação Nacional de Atividades Econômicas) formatting, keep only digits, and cap the result to the 7 digits of a complete subclass code. +- [parseNcm](https://brazilian-utils.com.br/utilities.md#parsencm): Remove NCM (Nomenclatura Comum do Mercosul) formatting, keep only digits, and cap the result to the 8 digits of a complete code. +- [parseCfop](https://brazilian-utils.com.br/utilities.md#parsecfop): Remove CFOP (Código Fiscal de Operações e Prestações) formatting, keep only digits, and cap the result to 4 digits. ## Generators (generate*) @@ -114,6 +120,7 @@ const { getCities } = await import('@brazilian-utils/brazilian-utils/get-cities' - [generateCnpj](https://brazilian-utils.com.br/utilities.md#generatecnpj): Generate a valid random CNPJ. - [generateBoleto](https://brazilian-utils.com.br/utilities.md#generateboleto): Generate a valid random boleto. - [generatePixPayload](https://brazilian-utils.com.br/utilities.md#generatepixpayload): Generates the payload of a Pix BR Code. +- [generateRenavam](https://brazilian-utils.com.br/utilities.md#generaterenavam): Generate a valid random RENAVAM: the 11 digit form, ten base digits plus the check digit. - [generatePassport](https://brazilian-utils.com.br/utilities.md#generatepassport): Generate a random valid Brazilian passport number. - [generateCep](https://brazilian-utils.com.br/utilities.md#generatecep): Generate a random CEP. - [generateCnh](https://brazilian-utils.com.br/utilities.md#generatecnh): Generate a valid random CNH. @@ -127,40 +134,47 @@ const { getCities } = await import('@brazilian-utils/brazilian-utils/get-cities' ## Getters (get*) - [getBoletoInfo](https://brazilian-utils.com.br/utilities.md#getboletoinfo): Extract information from a boleto (amount, expiration date, bank code). +- [getPixKeyInfo](https://brazilian-utils.com.br/utilities.md#getpixkeyinfo): Identifies a Pix key and normalizes it to the canonical form the DICT expects inside a BR Code: 11 digit CPF, 14 character CNPJ, lowercased e-mail, E.164 mobile phone (a landline is not a Pix key) or lowercase UUID EVP. +- [getPixPayloadInfo](https://brazilian-utils.com.br/utilities.md#getpixpayloadinfo): Parses a Pix BR Code payload into its fields. +- [getNfeKeyInfo](https://brazilian-utils.com.br/utilities.md#getnfekeyinfo): Parses a DF-e access key into its fields (stateCode, year, month, taxId, model, series, number, emissionType, code, checkDigit). - [getAreaCodeInfo](https://brazilian-utils.com.br/utilities.md#getareacodeinfo): Get the state (and its region) a Brazilian DDD (area code) belongs to, out of the 67 DDDs in use under the Anatel Plano Geral de Numeração. - [getAreaCodesByState](https://brazilian-utils.com.br/utilities.md#getareacodesbystate): Get every DDD (area code) that serves a given Brazilian state, under the Anatel Plano Geral de Numeração. - [getAddressInfoByCep](https://brazilian-utils.com.br/utilities.md#getaddressinfobycep): Fetch address information for a given CEP using multiple providers. - [getBanks](https://brazilian-utils.com.br/utilities.md#getbanks): Get every Brazilian bank with a compensation code (COMPE), published by Banco Central do Brasil in the STR participants list. - [getBankByCode](https://brazilian-utils.com.br/utilities.md#getbankbycode): Look a Brazilian bank up by its compensation code (COMPE), published by Banco Central do Brasil in the STR participants list. - [getBankByIspb](https://brazilian-utils.com.br/utilities.md#getbankbyispb): Look a Brazilian bank up by its ISPB (Identificador do Sistema de Pagamentos Brasileiro), the 8 digit code published by Banco Central do Brasil in the STR participants list. +- [getIbanInfo](https://brazilian-utils.com.br/utilities.md#getibaninfo): Parses a Brazilian IBAN into its fields: 2 (country code, always `BR`) + 2 (ISO 7064 MOD 97-10 check digits) + 8 (ISPB) + 5 (branch) + 10 (account) + 1 (account type, any letter, usually `C` for conta corrente or `P` for conta poupança) + 1 (owner indicator, `1` to `9` then `A` to `Z`). - [getStates](https://brazilian-utils.com.br/utilities.md#getstates): Get all Brazilian states, each with its two-letter code, name, region code, region name and 2-digit IBGE code of the Federative Unit (`cUF`). - [getStateByIbgeCode](https://brazilian-utils.com.br/utilities.md#getstatebyibgecode): Get the Brazilian state whose 2-digit IBGE code ("cUF", the Código da Unidade da Federação) matches the given value. - [getStateCodeByName](https://brazilian-utils.com.br/utilities.md#getstatecodebyname): Get the two-letter code (sigla) of a Brazilian state given its full name. - [getStateNameByCode](https://brazilian-utils.com.br/utilities.md#getstatenamebycode): Get the full name of a Brazilian state given its two-letter code (sigla). - [getTimezoneByState](https://brazilian-utils.com.br/utilities.md#gettimezonebystate): Get the IANA time zone database name (tzdata zone) for a Brazilian state, chosen as the zone of the state capital. -- [getCities](https://brazilian-utils.com.br/utilities.md#getcities): Get Brazilian cities. +- [getCities](https://brazilian-utils.com.br/utilities.md#getcities): Get Brazilian cities. Deprecated: use `getMunicipalities` instead. - [getHolidays](https://brazilian-utils.com.br/utilities.md#getholidays): Get Brazilian holidays for a given year. - [getCepInfoByAddress](https://brazilian-utils.com.br/utilities.md#getcepinfobyaddress): Fetch CEPs from an address using ViaCEP. - [getLegalNatures](https://brazilian-utils.com.br/utilities.md#getlegalnatures): Get the legal nature map keyed by code. +- [getLegalNaturesByCategory](https://brazilian-utils.com.br/utilities.md#getlegalnaturesbycategory): Get every legal nature of a CONCLA category, the group given by the first digit of the code: `1` Administração Pública, `2` Entidades Empresariais, `3` Entidades sem Fins Lucrativos, `4` Pessoas Físicas and `5` Organizações Internacionais e Outras Instituições Extraterritoriais. - [getLegalNature](https://brazilian-utils.com.br/utilities.md#getlegalnature): Look a legal nature code up in the official IBGE/CONCLA table. - [getFormatLicensePlate](https://brazilian-utils.com.br/utilities.md#getformatlicenseplate): Detect the normalized format of a license plate. -- [getMunicipality](https://brazilian-utils.com.br/utilities.md#getmunicipality): Get municipality information by IBGE code, or get an IBGE code from municipality name and UF. +- [getMunicipality](https://brazilian-utils.com.br/utilities.md#getmunicipality): Get municipality information by IBGE code, or get an IBGE code from municipality name and UF. Deprecated: use `getMunicipalityByCode` instead, which is synchronous and offline; matching a municipality by name is up to the application, over `getMunicipalities`. - [getMunicipalities](https://brazilian-utils.com.br/utilities.md#getmunicipalities): Get Brazilian municipalities published by the IBGE. - [getMunicipalityByCode](https://brazilian-utils.com.br/utilities.md#getmunicipalitybycode): Look up a Brazilian municipality by its 7-digit IBGE code. -- [getCbo](https://brazilian-utils.com.br/utilities.md#getcbo): Look a CBO (Classificação Brasileira de Ocupações) code up and get its official occupation title. -- [getCnae](https://brazilian-utils.com.br/utilities.md#getcnae): Look a CNAE (Classificação Nacional de Atividades Econômicas) subclass code up and get its formatted code and official description. -- [getCfop](https://brazilian-utils.com.br/utilities.md#getcfop): Look a CFOP (Código Fiscal de Operações e Prestações) code up and get its code and official description. +- [getCertidaoInfo](https://brazilian-utils.com.br/utilities.md#getcertidaoinfo): Parse the matrícula of a certidão de registro civil into its fields, returning `null` when the matrícula is not valid, which includes a book code that is not one of the nine books. +- [getCbo](https://brazilian-utils.com.br/utilities.md#getcbo): Look a CBO (Classificação Brasileira de Ocupações) code up and get its official occupation title, in the `{ code, description }` record every lookup of this library returns. +- [getCnae](https://brazilian-utils.com.br/utilities.md#getcnae): Look a CNAE (Classificação Nacional de Atividades Econômicas) subclass code up and get its code and official description. +- [getCfop](https://brazilian-utils.com.br/utilities.md#getcfop): Look a CFOP (Código Fiscal de Operações e Prestações) code up and get its code and official description, as the consolidated Anexo II of Convênio SINIEF s/nº 1970 words it, in the text in force, last amended by Ajuste SINIEF 39/25. ## Other utilities -- [capitalize](https://brazilian-utils.com.br/utilities.md#capitalize): Transforms the first letter into a capital one of each word ignoring prepositions. -- [convertNumberToWords](https://brazilian-utils.com.br/utilities.md#convertnumbertowords): Formats an integer as its Brazilian Portuguese cardinal number words ("por extenso"), e.g. `1235` becomes `"mil, duzentos e trinta e cinco"`. -- [convertCurrencyToWords](https://brazilian-utils.com.br/utilities.md#convertcurrencytowords): Formats a monetary amount in Brazilian Reais as its "por extenso" textual representation, the style used to write out the amount by hand on cheques and contracts, e.g. `1523.45` becomes `"mil, quinhentos e vinte e três reais e quarenta e cinco centavos"`. +- [capitalize](https://brazilian-utils.com.br/utilities.md#capitalize): Transforms the first letter into a capital one of each word, the way a Brazilian name, company name or address is written, with no options needed. +- [convertNumberToWords](https://brazilian-utils.com.br/utilities.md#convertnumbertowords): Formats an integer as its Brazilian Portuguese cardinal number words ("por extenso"), e.g. `1235` becomes `"mil duzentos e trinta e cinco"`. +- [convertCurrencyToWords](https://brazilian-utils.com.br/utilities.md#convertcurrencytowords): Formats a monetary amount in Brazilian Reais as its "por extenso" textual representation, the style used to write out the amount by hand on cheques and contracts, e.g. `1523.45` becomes `"mil quinhentos e vinte e três reais e quarenta e cinco centavos"`. - [convertLicensePlateToMercosul](https://brazilian-utils.com.br/utilities.md#convertlicenseplatetomercosul): Convert an old format Brazilian license plate (`LLLNNNN`) to the Mercosul format (`LLLNLNN`), following the official conversion table: the digit in the 5th position becomes a letter (`0` through `9` mapping to `A` through `J`). - [isHoliday](https://brazilian-utils.com.br/utilities.md#isholiday): Check if a specific date is a Brazilian holiday. - [isBusinessDay](https://brazilian-utils.com.br/utilities.md#isbusinessday): Check if a date is a Brazilian business day (dia útil). -- [addBusinessDays](https://brazilian-utils.com.br/utilities.md#addbusinessdays): Add a number of Brazilian business days (dias úteis) to a date, skipping Saturdays, Sundays and Brazilian holidays exactly as `isBusinessDay` defines them (same `stateCode`/`includeOptional` options). -- [differenceInBusinessDays](https://brazilian-utils.com.br/utilities.md#differenceinbusinessdays): Count the number of Brazilian business days (dias úteis) between two dates, mirroring the semantics of date-fns' `differenceInBusinessDays` (verified against its source): `params.from` is counted when it is itself a business day, `params.to` is never counted, and every business day strictly in between is counted once. +- [addBusinessDays](https://brazilian-utils.com.br/utilities.md#addbusinessdays): Add a number of Brazilian business days (dias úteis) to a date, skipping Saturdays, Sundays and Brazilian holidays exactly as `isBusinessDay` defines them (same `BusinessDayOptions`: `options.includeOptional`, default `true`, and `options.stateCode` work exactly as they do there). +- [subBusinessDays](https://brazilian-utils.com.br/utilities.md#subbusinessdays): Subtract a number of Brazilian business days (dias úteis) from a date: `subBusinessDays(date, amount, options?)` is `addBusinessDays(date, -amount, options)`, which is exactly how it is implemented, so every detail above (the preserved time-of-day, the untouched input, an `amount` of `0` returning the date unchanged, the 1900-2099 range and the `null` cases) holds here too, `options.stateCode` and `options.includeOptional` included. +- [differenceInBusinessDays](https://brazilian-utils.com.br/utilities.md#differenceinbusinessdays): Count the number of Brazilian business days (dias úteis) between two dates, mirroring the semantics of date-fns' `differenceInBusinessDays` (verified against its source), argument order included: `differenceInBusinessDays(laterDate, earlierDate, options?)`. - [convertDateToWords](https://brazilian-utils.com.br/utilities.md#convertdatetowords): Formats a date as its Brazilian Portuguese "por extenso" textual representation, e.g. `"01/01/2024"` becomes `"primeiro de janeiro de dois mil e vinte e quatro"`. - [removeAccents](https://brazilian-utils.com.br/utilities.md#removeaccents): Remove diacritical marks (accents, tildes, cedillas) from a string, decomposing every accented character into its base letter plus combining marks (Unicode NFD) and dropping the combining marks. diff --git a/docs/migration-v1-to-v2.md b/docs/migration-v1-to-v2.md index 45f40c92..e6fe8b62 100644 --- a/docs/migration-v1-to-v2.md +++ b/docs/migration-v1-to-v2.md @@ -148,7 +148,6 @@ To make the migration easier, **v2.x still exports the old PascalCase names as d | `formatCPF` | `formatCpf` | | `formatCNPJ` | `formatCnpj` | | `formatCEP` | `formatCep` | -| `formatPIS` | `formatPis` | | `formatProcessoJuridico` | `formatProcessoJuridico` (unchanged) | | `formatBoleto` | `formatBoleto` (unchanged) | | `formatCurrency` | `formatCurrency` (unchanged) | @@ -182,7 +181,7 @@ generateCnpj(); // Currently generates numeric (v1), but will be random in v3.0. | `parseCurrency` | `parseCurrency` (unchanged) | | `capitalize` | `capitalize` (unchanged) | | `getStates` | `getStates` (unchanged) | -| `getCities` | `getCities` (unchanged) | +| `getCities` | `getCities` (unchanged; deprecated in 2.4.0 in favour of `getMunicipalities`) | | `getAddressInfoByCep` | `getAddressInfoByCep` (API changed, see below) | ### Migration Example @@ -238,16 +237,12 @@ if (index === input.length - 1) { /* ... */ } ``` #### `generateChecksum` -This function is now internal and no longer exported in the public API. +This function is now internal and no longer exported in the public API. The package exports no internals: `dist/_internals` is not published and there is no subpath for it, so there is no supported way to import this function in v2. Inline the check digit calculation you need instead. **Migration:** ```javascript // v1 - Don't use this anymore import { generateChecksum } from '@brazilian-utils/brazilian-utils'; - -// v2 - If you absolutely need it, import from internals (not recommended) -// This is not part of the public API and may change without notice -import { generateChecksum } from '@brazilian-utils/brazilian-utils/dist/_internals/generate-checksum/generate-checksum'; ``` #### `generateRandomNumber` @@ -304,9 +299,9 @@ Format phone numbers according to Brazilian patterns. ```javascript import { formatPhone } from '@brazilian-utils/brazilian-utils'; -formatPhone('11900000000'); // 90000-0000 +formatPhone('11900000000'); // 11900-0000 (BEWARE: default "sn" truncates a DDD-prefixed number) formatPhone('11900000000', { mask: 'nanp' }); // (11) 90000-0000 -formatPhone('11900000000', { mask: 'auto' }); // Auto-detects mask +formatPhone('11900000000', { mask: 'auto' }); // (11) 90000-0000 ``` ### `isValidRenavam` @@ -331,26 +326,26 @@ import { isValidBankAccount } from '@brazilian-utils/brazilian-utils'; // Banco do Brasil isValidBankAccount({ bankCode: '001', - agency: '1234', - account: '12345678', - digit: '5' -}); // true (if valid) + agency: '1584', + account: '00210169', + digit: '6' +}); // true // Itaú isValidBankAccount({ bankCode: '341', - agency: '1234', - account: '12345', - digit: '6' -}); // true (if valid) + agency: '2545', + account: '02366', + digit: '1' +}); // true // Other banks use generic validation isValidBankAccount({ - bankCode: '999', + bankCode: '246', agency: '1234', account: '123456', - digit: '7' -}); // true (if mod10/mod11 validation passes) + digit: '6' +}); // true (the digit matches mod10) ``` ## API Changes @@ -438,4 +433,4 @@ If you encounter any issues during migration, please: 1. Check the [utilities documentation](utilities.md) for the correct function signatures 2. Review the examples in this migration guide -3. Open an issue on the [GitHub repository](https://github.com/brazilian-utils/brazilian-utils) if you find a bug +3. Open an issue on the [GitHub repository](https://github.com/brazilian-utils/javascript) if you find a bug diff --git a/docs/pt-br/getting-started.md b/docs/pt-br/getting-started.md index 8f28bc9a..3b84740e 100644 --- a/docs/pt-br/getting-started.md +++ b/docs/pt-br/getting-started.md @@ -5,7 +5,7 @@ Brazilian Utils é uma biblioteca com foco na resolução de problemas que enfre ## Por que Brazilian Utils - **Zero dependências de runtime.** Nada além da lib entra no seu `node_modules` ou no seu bundle. -- **Tree-shakeable até a função.** `import { isValidCpf }` custa menos de 1 KB; cada utilitário também é um subpath próprio (`@brazilian-utils/brazilian-utils/get-cities`) para os mais pesados. +- **Tree-shakeable até a função.** `import { isValidCpf }` custa cerca de 1,4 KB minificado (0,8 KB com gzip); cada utilitário também é um subpath próprio (`@brazilian-utils/brazilian-utils/get-cities`) para os mais pesados. - **Roda em qualquer lugar.** Node.js `^20.19.0 || >=22.12.0`, Bun, Deno e navegadores modernos, testados no CI em todos eles. - **Escrita em TypeScript.** Os tipos vêm no pacote; a API pública é acompanhada por um relatório de API, então nada muda em silêncio. - **Validada contra as regras oficiais.** Cada validador cita a especificação, lei ou base de dados que implementa (`@see` na documentação), e a suíte de testes passa por mutation testing, não só por cobertura. @@ -63,19 +63,19 @@ Você pode conferir a lista de utilitários [clicando aqui](utilities.md). ## Tamanho do bundle -O pacote é tree-shakeable: importar um utilitário da raiz traz apenas o código daquele utilitário, não o resto da biblioteca. `isValidCpf`, por exemplo, adiciona cerca de 0,7 KB minificado ao seu bundle. Um bundler com suporte a tree-shaking (webpack, Rollup, esbuild, Vite etc.) descarta todos os outros utilitários. +O pacote é tree-shakeable: importar um utilitário da raiz traz apenas o código daquele utilitário, não o resto da biblioteca. `isValidCpf`, por exemplo, adiciona cerca de 1,4 KB minificado (0,8 KB com gzip) ao seu bundle. Um bundler com suporte a tree-shaking (webpack, Rollup, esbuild, Vite, etc.) descarta todos os outros utilitários. Alguns utilitários são a exceção: cada um embute um dataset oficial e pesa muito mais que todos os outros utilitários somados. Estes são os tamanhos de um import isolado, minificado e com gzip: | Utilitário | Dataset | Minificado | Gzip | | --- | --- | --- | --- | -| `getMunicipalities` · `getMunicipalityByCode` · `getMunicipality` | 5571 municípios do IBGE, com nomes e códigos | 156 KB | 50 KB | -| `getCities` | nomes dos 5571 municípios do IBGE | 153 KB | 49 KB | -| `isValidNcm` | códigos NCM (Nomenclatura Comum do Mercosul) | 113 KB | 24 KB | -| `isValidCbo` · `getCbo` | títulos das ocupações da CBO 2002 | 110 KB | 27 KB | -| `isValidCnae` · `getCnae` | subclasses da CNAE 2.3 | 93 KB | 21 KB | -| `isValidCfop` · `getCfop` | descrições das operações do CFOP | 55 KB | 5,4 KB | -| `getBanks` · `getBankByCode` | participantes do STR do Banco Central (COMPE + ISPB) | 28 KB | 7,3 KB | +| `getMunicipalities` · `getMunicipalityByCode` · `getMunicipality` | 5571 municípios do IBGE, com nomes e códigos | 154,9 - 156,5 KB | 50,3 - 50,4 KB | +| `getCities` | nomes dos 5571 municípios do IBGE | 154,2 KB | 49,8 KB | +| `isValidNcm` | códigos NCM (Nomenclatura Comum do Mercosul) | 114,1 KB | 24,6 KB | +| `isValidCbo` · `getCbo` | títulos das ocupações da CBO 2002 | 119,1 KB | 30,6 KB | +| `isValidCnae` · `getCnae` | CNAE-Subclasses 2.3 | 93,9 KB | 21,2 KB | +| `isValidCfop` · `getCfop` | descrições das operações do CFOP | 68,9 KB | 6,9 KB | +| `getBanks` · `getBankByCode` | participantes do STR do Banco Central (COMPE + ISPB) | 38,3 - 38,6 KB | 9,5 - 9,7 KB | Importar qualquer um deles da raiz, mesmo ao lado de um único utilitário pequeno, traz todo esse dataset para o seu bundle principal, porque este pacote é publicado como um único módulo ESM: um `import()` dinâmico da raiz (`await import('@brazilian-utils/brazilian-utils')`) ainda resolve para esse mesmo arquivo único, então não há como separá-lo sozinho. Um bundler que faz code-splitting precisa de um módulo separado para separar. @@ -97,4 +97,4 @@ getMunicipalityByCode('3550308'); Todos os utilitários estão disponíveis dessa forma, como `@brazilian-utils/brazilian-utils/` (kebab-case, seguindo o nome da função: `isValidCpf` → `is-valid-cpf`), pelo mesmo motivo de lazy-loading/code-splitting. -Escolha um estilo por utilitário em cada aplicação: um bundler trata o import da raiz e o import do subpath como dois módulos independentes, então importar `getCities` tanto da raiz quanto de `/get-cities` na mesma aplicação inclui a tabela de 153 KB de cidades duas vezes, uma em cada módulo. +Escolha um estilo por utilitário em cada aplicação: um bundler trata o import da raiz e o import do subpath como dois módulos independentes, então importar `getCities` tanto da raiz quanto de `/get-cities` na mesma aplicação inclui a tabela de 154,2 KB de cidades duas vezes, uma em cada módulo. diff --git a/docs/pt-br/migration-v1-to-v2.md b/docs/pt-br/migration-v1-to-v2.md index f45cf647..c8e3e6a8 100644 --- a/docs/pt-br/migration-v1-to-v2.md +++ b/docs/pt-br/migration-v1-to-v2.md @@ -148,7 +148,6 @@ Para facilitar a migração, **a v2.x ainda exporta os nomes antigos em PascalCa | `formatCPF` | `formatCpf` | | `formatCNPJ` | `formatCnpj` | | `formatCEP` | `formatCep` | -| `formatPIS` | `formatPis` | | `formatProcessoJuridico` | `formatProcessoJuridico` (inalterado) | | `formatBoleto` | `formatBoleto` (inalterado) | | `formatCurrency` | `formatCurrency` (inalterado) | @@ -182,7 +181,7 @@ generateCnpj(); // Atualmente gera numérico (v1), mas será aleatório na v3.0. | `parseCurrency` | `parseCurrency` (inalterado) | | `capitalize` | `capitalize` (inalterado) | | `getStates` | `getStates` (inalterado) | -| `getCities` | `getCities` (inalterado) | +| `getCities` | `getCities` (inalterado; descontinuado na 2.4.0 em favor de `getMunicipalities`) | | `getAddressInfoByCep` | `getAddressInfoByCep` (API alterada, veja abaixo) | ### Exemplo de Migração @@ -238,16 +237,12 @@ if (index === input.length - 1) { /* ... */ } ``` #### `generateChecksum` -Esta função agora é interna e não é mais exportada na API pública. +Esta função agora é interna e não é mais exportada na API pública. O pacote não exporta internals: `dist/_internals` não é publicado e não existe subpath para ele, então não há forma suportada de importar essa função na v2. Calcule o dígito verificador que você precisa no seu próprio código. **Migração:** ```javascript // v1 - Não use mais isso import { generateChecksum } from '@brazilian-utils/brazilian-utils'; - -// v2 - Se você absolutamente precisar, importe dos internals (não recomendado) -// Isto não faz parte da API pública e pode mudar sem aviso -import { generateChecksum } from '@brazilian-utils/brazilian-utils/dist/_internals/generate-checksum/generate-checksum'; ``` #### `generateRandomNumber` @@ -304,9 +299,9 @@ Formata números de telefone de acordo com padrões brasileiros. ```javascript import { formatPhone } from '@brazilian-utils/brazilian-utils'; -formatPhone('11900000000'); // 90000-0000 +formatPhone('11900000000'); // 11900-0000 (CUIDADO: a máscara padrão "sn" trunca um número com DDD) formatPhone('11900000000', { mask: 'nanp' }); // (11) 90000-0000 -formatPhone('11900000000', { mask: 'auto' }); // Detecta automaticamente a máscara +formatPhone('11900000000', { mask: 'auto' }); // (11) 90000-0000 ``` ### `isValidRenavam` @@ -331,26 +326,26 @@ import { isValidBankAccount } from '@brazilian-utils/brazilian-utils'; // Banco do Brasil isValidBankAccount({ bankCode: '001', - agency: '1234', - account: '12345678', - digit: '5' -}); // true (se válido) + agency: '1584', + account: '00210169', + digit: '6' +}); // true // Itaú isValidBankAccount({ bankCode: '341', - agency: '1234', - account: '12345', - digit: '6' -}); // true (se válido) + agency: '2545', + account: '02366', + digit: '1' +}); // true // Outros bancos usam validação genérica isValidBankAccount({ - bankCode: '999', + bankCode: '246', agency: '1234', account: '123456', - digit: '7' -}); // true (se validação mod10/mod11 passar) + digit: '6' +}); // true (o dígito corresponde ao mod10) ``` ## Mudanças na API @@ -438,4 +433,4 @@ Se você encontrar problemas durante a migração, por favor: 1. Verifique a [documentação de utilitários](/pt-br/utilities.md) para as assinaturas corretas das funções 2. Revise os exemplos neste guia de migração -3. Abra uma issue no [repositório GitHub](https://github.com/brazilian-utils/brazilian-utils) se encontrar um bug +3. Abra uma issue no [repositório GitHub](https://github.com/brazilian-utils/javascript) se encontrar um bug diff --git a/docs/pt-br/utilities.md b/docs/pt-br/utilities.md index 28c5b00a..54395e9c 100644 --- a/docs/pt-br/utilities.md +++ b/docs/pt-br/utilities.md @@ -2,7 +2,7 @@ Aqui você encontrará todos os utilitários disponíveis para uso. -> **Tratamento de entrada:** nenhuma função pública síncrona lança exceção com `null`/`undefined` ou um valor de tipo incorreto; as duas funções de rede, `getAddressInfoByCep` e `getCepInfoByAddress`, rejeitam com seus erros tipados (veja as seções delas). Os validadores (`isValid*`) retornam `false`; `isHoliday` retorna `false`; `getHolidays` retorna `[]`; `generateProcessoJuridico` retorna `null`; `getMunicipality` retorna `null` para uma busca malformada/sem correspondência. Todas as demais funções `format*`/`parse*` (incluindo `capitalize`) retornam um valor vazio do seu tipo de retorno: `""` para strings, `0` para `parseCurrency`. `formatCurrency` retorna `""` para um número não finito. +> **Tratamento de entrada:** nenhuma função pública síncrona lança exceção com `null`/`undefined` ou um valor de tipo incorreto; as duas funções de rede, `getAddressInfoByCep` e `getCepInfoByAddress`, rejeitam com seus erros tipados (veja as seções delas). Os validadores (`isValid*`) retornam `false`; `isHoliday` retorna `false`; `getHolidays` retorna `[]`; `getBoletoInfo` retorna `null` para um boleto inválido; `generateProcessoJuridico` retorna `null`; `getMunicipality` retorna `null` para uma busca malformada/sem correspondência. Todas as demais funções `format*`/`parse*` retornam um valor vazio do seu tipo de retorno: toda função `format*`, `capitalize`, e as funções `parse*` que retornam string (`parseBoleto`, `parseCaepf`, `parseCbo`, `parseCei`, `parseCep`, `parseCertidao`, `parseCfop`, `parseCnae`, `parseCnh`, `parseCno`, `parseCnpj`, `parseCns`, `parseCpf`, `parseIban`, `parseLegalNature`, `parseLicensePlate`, `parseNcm`, `parseNfeKey`, `parsePassport`, `parsePhone`, `parsePis`, `parseProcessoJuridico`, `parseVoterId`) retornam `""`; `parseCurrency` retorna `0`; os leitores estruturados — `getCertidaoInfo`, `getIbanInfo`, `getNfeKeyInfo`, `getPixKeyInfo`, `getPixPayloadInfo` — retornam `null`. `formatCurrency` retorna `""` para um número não finito e para um valor que não pode ser convertido em número (um symbol, um objeto simples, um objeto sem protótipo); `null`, arrays e booleanos passam por `Number()` como no 2.3.0. A única exceção à promessa acima: um objeto criado com `Object.create(null)` não tem `toString`, então as funções `format*`/`parse*` que leem a entrada como texto ainda lançam um `TypeError` para ele, exatamente como na 2.3.0. ## isValidCpf @@ -17,7 +17,7 @@ isValidCpf('111 444 777 35'); // true (máscara com espaços) ## formatCpf -Formata o CPF. `options.obfuscate` (parte de `FormatCpfOptions`) esconde os 3 primeiros dígitos e os 2 dígitos verificadores (`***.456.789-**`), a convenção de exibição do gov.br / Receita Federal, aplicada após o `pad`. +Formata o CPF. `options.pad` (parte de `FormatCpfOptions`) preenche o valor com zeros à esquerda até as 11 posições do padrão antes de aplicar a máscara (padrão `false`). `options.obfuscate` (do mesmo tipo) esconde os 3 primeiros dígitos e os 2 dígitos verificadores (`***.456.789-**`), a convenção de exibição do gov.br / Receita Federal, aplicada após o `pad`. É lida por veracidade (truthiness), do mesmo jeito que o `pad`, então qualquer valor verdadeiro esconde os dígitos. ```javascript import { formatCpf } from '@brazilian-utils/brazilian-utils'; @@ -39,7 +39,7 @@ parseCpf('746.506.880-00'); // 74650688000 ## generateCpf -Gera um CPF válido aleatório. +Gera um CPF válido aleatório. Usa `Math.random()` internamente, então não é criptograficamente seguro. ```javascript import { generateCpf } from '@brazilian-utils/brazilian-utils' @@ -50,7 +50,7 @@ generateCpf('SP'); // o 9º dígito é 8, o código da região fiscal de SP ## isValidCnpj -Valida se o CNPJ é válido. Suporta tanto o formato numérico (`version: 1`, padrão) quanto o formato alfanumérico (`version: 2`), e aceita os caracteres de máscara usuais e espaços em branco. As opções são tipadas como `IsValidCnpjOptions`. +Valida se o CNPJ é válido. `options.version` (parte de `IsValidCnpjOptions`) escolhe qual formato é aceito: `1` (padrão) apenas o formato numérico, `2` tanto o numérico quanto o alfanumérico; qualquer outro valor é lido como `1`, do mesmo jeito que `formatCnpj` e `parseCnpj` o leem. Os caracteres de máscara usuais e espaços em branco são aceitos nas duas versões. A versão `2` não tem lista de valores reservados, porque o manual da Receita Federal não define nenhuma para o formato alfanumérico: uma base alfanumérica de caracteres repetidos (todos `A`, por exemplo) que passe no dígito verificador é aceita, enquanto os números reservados numéricos são rejeitados na versão `1`. ```javascript import { isValidCnpj } from '@brazilian-utils/brazilian-utils'; @@ -61,7 +61,7 @@ isValidCnpj('q0slfmbd7vx439', { version: 2 }); // true (alfanumérico minúsculo ## formatCnpj -Formata o CNPJ. `options.obfuscate` (parte de `FormatCnpjOptions`) esconde os 2 primeiros dígitos e os 2 dígitos verificadores (`**.345.678/0001-**`), a convenção de exibição do gov.br / Receita Federal. Vale para as duas versões e é aplicada após o `pad`. +Formata o CNPJ. `options.pad` (parte de `FormatCnpjOptions`) preenche o valor com zeros à esquerda até as 14 posições do padrão antes de aplicar a máscara (padrão `false`). `options.version` (do mesmo tipo) escolhe qual formato de CNPJ é lido: `1` (padrão) apenas numérico, `2` alfanumérico. `options.obfuscate` esconde os 2 primeiros dígitos e os 2 dígitos verificadores (`**.345.678/0001-**`), a convenção de exibição do gov.br / Receita Federal. Vale para as duas versões, é aplicada após o `pad` e é lida por veracidade (truthiness), do mesmo jeito que o `pad`, então qualquer valor verdadeiro esconde os dígitos. ```javascript import { formatCnpj } from '@brazilian-utils/brazilian-utils'; @@ -74,7 +74,7 @@ formatCnpj('12345678000195', { obfuscate: true }); // **.345.678/0001-** ## parseCnpj -Remove a formatação do CNPJ, retorna um valor normalizado e limita o resultado a 14 caracteres. As opções são tipadas como `ParseCnpjOptions`. +Remove a formatação do CNPJ, retorna um valor normalizado e limita o resultado a 14 caracteres. `options.version` (parte de `ParseCnpjOptions`) escolhe qual formato de CNPJ é normalizado: `1` (padrão) mantém apenas dígitos, `2` mantém letras e dígitos, de modo que um CNPJ alfanumérico sobrevive à ida e volta. ```javascript import { parseCnpj } from '@brazilian-utils/brazilian-utils'; @@ -85,7 +85,7 @@ parseCnpj('12.OUT.345/0001-99', { version: 2 }); // 12OUT345000199 ## isValidCep -Valida se o CEP é válido. Aceita entrada como `string` ou `number`; espaços, pontos e hífens ao redor/entre os 8 dígitos são ignorados, mas qualquer outro caractere, uma letra em especial, invalida o valor. +Valida se o CEP ([código de endereçamento postal](https://pt.wikipedia.org/wiki/C%C3%B3digo_de_Endere%C3%A7amento_Postal)) é válido. Aceita entrada como `string` ou `number`, mas um CEP que começa com `0` precisa ser passado como string, já que um número não preserva o zero à esquerda (`isValidCep(1310100)` é `false`, `isValidCep('01310100')` é `true`); espaços, pontos e hífens ao redor/entre os 8 dígitos são ignorados, mas qualquer outro caractere, uma letra em especial, invalida o valor. ```javascript import { isValidCep } from '@brazilian-utils/brazilian-utils'; @@ -93,7 +93,7 @@ import { isValidCep } from '@brazilian-utils/brazilian-utils'; isValidCep('01310100'); // true isValidCep('92500-000'); // true (hífen entre os grupos) isValidCep('92.500-000'); // true (ponto e hífen) -isValidCep('013 10 100'); // true (espaços entre os dígitos) +isValidCep('013 10 100'); // true (espaços em qualquer posição entre os dígitos) isValidCep(20040020); // true (entrada numérica) isValidCep('9250000A'); // false (letras são rejeitadas) isValidCep('12345'); // false (tamanho inválido) @@ -101,18 +101,20 @@ isValidCep('12345'); // false (tamanho inválido) ## generateCnpj -Gera um CNPJ válido aleatório. +Gera um CNPJ válido aleatório. Usa `Math.random()` internamente, então não é criptograficamente seguro. O primeiro argumento é a versão, como antes, ou um objeto `GenerateCnpjOptions` com a mesma `version` mais `branch`, o bloco do "número de ordem" (filial) nas posições 9 a 12: um inteiro de 1 a 9999 escrito com zeros à esquerda em quatro caracteres, aleatório por padrão. Um `branch` inválido é ignorado e um bloco aleatório é usado, e o bloco continua numérico na versão alfanumérica. ```javascript import { generateCnpj } from '@brazilian-utils/brazilian-utils' generateCnpj(); generateCnpj(2); // CNPJ alfanumérico, ex. 'Q0SLFMBD7VX439' +generateCnpj({ branch: 3 }); // bloco de ordem '0003', ex. '12345678000372' +generateCnpj({ version: 2, branch: 1 }); // CNPJ alfanumérico cujo bloco de ordem é '0001' ``` ## isValidBoleto -Valida se o boleto é válido. Suporta tanto o boleto de "cobrança bancária" de 47 dígitos quanto o "boleto de arrecadação" (convênio/tributos): seja a linha digitável de 48 dígitos, seja o código de barras de 44 dígitos, ambos iniciados com `8`. +Valida se o boleto ([meio de pagamento brasileiro](https://pt.wikipedia.org/wiki/Boleto_banc%C3%A1rio)) é válido. Suporta tanto o boleto de "cobrança bancária" de 47 dígitos quanto o "boleto de arrecadação" (convênio/tributos): seja a linha digitável de 48 dígitos, seja o código de barras de 44 dígitos, ambos iniciados com `8`. Uma tolerância é mantida desde a 2.3.0: o código de moeda na posição 4 do código de barras da cobrança bancária não é verificado, embora a Carta-Circular BCB nº 2.926/2000 o fixe em `9` (real), então um boleto com qualquer outro dígito de moeda continua válido. ```javascript import { isValidBoleto } from '@brazilian-utils/brazilian-utils'; @@ -123,7 +125,7 @@ isValidBoleto('846100000005246100291102005460339004695895061080'); // true (bole ## formatBoleto -Formata um número de boleto. A máscara de arrecadação (convênio/tributos) só se aplica à linha digitável de 48 dígitos que começa com `8`; o código de barras de arrecadação de 44 dígitos não tem agrupamento de exibição definido pela FEBRABAN e mantém a máscara de "cobrança bancária". +Formata um número de boleto. `options.pad` (parte de `FormatBoletoOptions`) preenche o valor com zeros à esquerda até o número de posições do padrão antes de aplicar a máscara (padrão `false`). A máscara de arrecadação (convênio/tributos) só se aplica à linha digitável de 48 dígitos que começa com `8`; o código de barras de arrecadação de 44 dígitos não tem agrupamento de exibição definido pela FEBRABAN e mantém a máscara de "cobrança bancária". ```javascript import { formatBoleto } from '@brazilian-utils/brazilian-utils'; @@ -146,7 +148,7 @@ parseBoleto('00190.00009 01149.718601 68524.522114 6 75860000102656'); // 001900 ## generateBoleto -Gera um boleto válido aleatório. Informe `{ type: "arrecadacao" }` (tipado como `GenerateBoletoOptions`) para gerar um boleto de arrecadação em vez do tipo padrão "bancario" (cobrança bancária). +Gera um boleto válido aleatório. Informe `{ type: "arrecadacao" }` (tipado como `GenerateBoletoOptions`) para gerar um boleto de arrecadação em vez do tipo padrão "bancario" (cobrança bancária). Um boleto de arrecadação sorteia o segmento entre 1 e 7 (o segmento 9 é de uso dos próprios bancos) e o identificador de valor entre os quatro valores possíveis, `6` e `8` para valor efetivo e `7` e `9` para quantidade de referência, de modo que os dois ramos de `hasEffectiveValue` do `getBoletoInfo` sejam alcançáveis. ```javascript import { generateBoleto } from '@brazilian-utils/brazilian-utils'; @@ -157,7 +159,7 @@ generateBoleto({ type: 'arrecadacao' }); // "84610000000524610029110200546033900 ## getBoletoInfo -Extrai informações de um boleto (valor, data de vencimento, código do banco). Aceita opcionalmente `{ referenceDate }` (tipado como `GetBoletoInfoOptions`) para resolver o ciclo do "fator de vencimento" a partir de uma data específica em vez de agora (o ciclo do fator reiniciou em 22/02/2025, segundo a FEBRABAN). Para um boleto de arrecadação, o resultado, tipado como `BoletoInfo`, não tem `bankCode`/`expirationDate` e traz em vez disso `type: "arrecadacao"`, `segment`, `value` e `hasEffectiveValue`. +Extrai informações de um boleto (valor, data de vencimento, código do banco). Retorna `null` quando `value` não é um boleto válido — o `isValidBoleto` é verificado antes —, então o resultado precisa ser estreitado antes de ser lido. A 2.3.0 retornava `undefined` aqui; agora todo getter do pacote responde com `null` a uma busca que não resolve, então só uma comparação estrita `=== undefined` é afetada. Aceita opcionalmente `{ referenceDate }` (tipado como `GetBoletoInfoOptions`) para resolver o ciclo do "fator de vencimento" a partir de uma data específica em vez de agora (o ciclo de data-base do fator reiniciou em 22/02/2025, segundo a FEBRABAN). Nem a FEBRABAN nem o Banco Central publicam uma forma de distinguir um fator do ciclo antigo de um do ciclo novo, então todo fator resolve para uma de duas datas separadas por 9000 dias e o `referenceDate` escolhe entre elas por meio das janelas de segurança da própria biblioteca: o mesmo boleto pode passar a resolver para a outra candidata com o tempo, então informe `referenceDate` explicitamente sempre que a resposta precisar ser estável. A busca de ciclo nunca desce abaixo do primeiro ciclo, então um `referenceDate` anterior ao próprio esquema ainda resolve um fator para a data mais antiga que aquele fator consegue representar, em vez de uma anterior à data-base de 07/10/1997. Para um boleto de arrecadação, o resultado, tipado como `BoletoInfo`, continua trazendo as duas chaves, porém vazias, `bankCode: ''` e `expirationDate: null`, já que o boleto não tem código de banco nem fator de vencimento, e acrescenta `type: "arrecadacao"`, `segment`, `value` e `hasEffectiveValue`. ```javascript import { getBoletoInfo } from '@brazilian-utils/brazilian-utils'; @@ -172,6 +174,8 @@ getBoletoInfo('00190000090114971860168524522114675860000102656', { getBoletoInfo('846100000005246100291102005460339004695895061080'); // { amount: 2461, expirationDate: null, bankCode: '', type: 'arrecadacao', segment: 4, value: 24.61, hasEffectiveValue: true } + +getBoletoInfo('invalid'); // null ``` ## isValidPixKey @@ -190,26 +194,26 @@ isValidPixKey('123.456.789-09', { accept: ['email', 'evp'] }); // false isValidPixKey('not a key'); // false ``` -## parsePixKey +## getPixKeyInfo -Identifica uma chave Pix e a normaliza para a forma canônica que o DICT espera dentro do BR Code: CPF com 11 dígitos, CNPJ com 14 caracteres, e-mail em minúsculas, telefone celular em E.164 (um telefone fixo não é chave Pix) ou UUID em minúsculas (EVP). Um valor de 11 dígitos válido tanto como CPF quanto como celular é lido como CPF, a menos que tenha sido escrito como telefone (prefixo `+55`/`0055` ou DDD entre parênteses). O CPF e o telefone são reconhecidos pela forma como são escritos, não apenas pelos dígitos que carregam, então texto ao redor não é descartado e `'abc123.456.789-09'` não é uma chave CPF. Retorna `null` quando o valor não é uma chave Pix válida. O resultado é tipado como `PixKey`. +Identifica uma chave Pix e a normaliza para a forma canônica que o DICT espera dentro do BR Code: CPF com 11 dígitos, CNPJ com 14 caracteres, e-mail em minúsculas, telefone celular em E.164 (um telefone fixo não é chave Pix) ou UUID em minúsculas (EVP). Um valor de 11 dígitos válido tanto como CPF quanto como celular é lido como CPF, a menos que tenha sido escrito como telefone (prefixo `+55`/`0055` ou DDD entre parênteses). O CPF e o telefone são reconhecidos pela forma como são escritos, não apenas pelos dígitos que carregam, então texto ao redor não é descartado e `'abc123.456.789-09'` não é uma chave CPF. Uma chave de e-mail é trimada e passada para minúsculas, e uma maior que os 77 caracteres que o DICT permite é rejeitada. Um valor cujos dígitos carregam um dígito verificador de CNPJ válido é lido como CNPJ mesmo quando começa com `0055`, já que uma chave de telefone dentro do BR Code sempre carrega o prefixo `+55`. Retorna `null` quando o valor não é uma chave Pix válida. O resultado é tipado como `PixKeyInfo`. ```javascript -import { parsePixKey } from '@brazilian-utils/brazilian-utils'; +import { getPixKeyInfo } from '@brazilian-utils/brazilian-utils'; -parsePixKey('123.456.789-09'); // { type: 'cpf', value: '12345678909' } -parsePixKey('Fulano@Example.COM '); // { type: 'email', value: 'fulano@example.com' } -parsePixKey('(11) 98765-4321'); // { type: 'phone', value: '+5511987654321' } -parsePixKey('71C7D9BE-4B85-4E43-9F1C-1F3B8B4E9A2D'); +getPixKeyInfo('123.456.789-09'); // { type: 'cpf', value: '12345678909' } +getPixKeyInfo('Fulano@Example.COM '); // { type: 'email', value: 'fulano@example.com' } +getPixKeyInfo('(11) 98765-4321'); // { type: 'phone', value: '+5511987654321' } +getPixKeyInfo('71C7D9BE-4B85-4E43-9F1C-1F3B8B4E9A2D'); // { type: 'evp', value: '71c7d9be-4b85-4e43-9f1c-1f3b8b4e9a2d' } -parsePixKey('(11) 3000-0000'); // null (telefone fixo não é chave Pix) -parsePixKey('51998259765'); // { type: 'cpf', value: '51998259765' } (também é um telefone válido) -parsePixKey('+5551998259765'); // { type: 'phone', value: '+5551998259765' } +getPixKeyInfo('(11) 3000-0000'); // null (telefone fixo não é chave Pix) +getPixKeyInfo('51998259765'); // { type: 'cpf', value: '51998259765' } (também é um telefone válido) +getPixKeyInfo('+5551998259765'); // { type: 'phone', value: '+5551998259765' } ``` ## isValidPixPayload -Valida se um payload de BR Code Pix (a string por trás de um QR Code Pix e do "Pix copia e cola") é válido: estrutura TLV bem formada, objetos obrigatórios presentes, um dos templates "Merchant Account Information" carregando o GUI `br.gov.bcb.pix` junto com uma chave ou uma URL, um objeto "Point of Initiation Method" (`01`) coerente com ele (uma chave exige um payload estático, com `01` ausente ou `"11"`; uma URL exige um dinâmico, com `01` igual a `"12"`), um valor (`54`) maior que zero em um payload estático, e um CRC-16 que confere. A chave em si não é validada contra os formatos do DICT, use `isValidPixKey` para isso. Payloads que trazem a localização em um Unreserved Template (IDs 80 a 99), como o "QR Code composto" do Pix Automático (Pix recorrente), estão fora de escopo e são considerados inválidos. +Valida se um payload de BR Code Pix (a string por trás de um QR Code Pix e do "Pix copia e cola") é válido: estrutura TLV bem formada, objetos obrigatórios presentes, um dos templates "Merchant Account Information" carregando o GUI `br.gov.bcb.pix` junto com uma chave ou uma URL, e um CRC-16 que confere. O objeto "Point of Initiation Method" (`01`) é informativo: o Manual do BR Code o marca como opcional e só atribui significado ao valor `"12"` ("só pode ser utilizado uma vez"), então ele pode estar ausente em qualquer um dos formatos e apenas um valor fora de `{"11", "12"}` torna o payload inválido. Quando um payload construído em torno de uma chave traz um valor (`54`), esse valor precisa ser maior que zero, a menos que o payload seja um BR Code de Pix Saque, ou seja, a menos que traga o ISPB do facilitador de serviço de saque no subobjeto 26-03 (`fss`) como prescreve o §2.6 do manual do Pix; rejeitar `"0"`/`"0.00"` sem o `fss` é uma restrição deliberada desta biblioteca, não uma regra do manual. Um `fss` escrito ao lado de uma localização de PSP torna o payload inválido: o §2.7 do Manual de Padrões para Iniciação do Pix mapeia o QR Code dinâmico para exatamente dois subobjetos, `00` (GUI) e `25` (URL), e o `fss` pertence ao template estático do §2.6. A chave em si não é validada contra os formatos do DICT, use `isValidPixKey` para isso. Os Unreserved Templates (IDs 80 a 99) são ignorados: o "QR Code composto" do Pix Automático (Pix recorrente) grava em um deles a localização de recorrência e, quando esse payload também traz uma localização de pagamento em 26-25, como no exemplo composto do manual do Pix, ele é aceito e lido como um payload dinâmico comum, com a localização de recorrência descartada. Só um payload sem nenhum template Pix nos IDs 26 a 51 é considerado inválido. ```javascript import { isValidPixPayload } from '@brazilian-utils/brazilian-utils'; @@ -222,29 +226,30 @@ isValidPixPayload( isValidPixPayload('00020126580014br.gov.bcb.pix...'); // false (CRC quebrado) ``` -## parsePixPayload +## getPixPayloadInfo -Interpreta um payload de BR Code Pix e retorna seus campos. O payload é validado pelo `isValidPixPayload` primeiro, então uma estrutura malformada, um CRC quebrado ou um objeto obrigatório ausente retornam `null` em vez de um resultado parcial. Um payload estático vem com `key`, um dinâmico com `url`. O resultado é tipado como `PixPayload`; `pointOfInitiation` é tipado como `PixPointOfInitiation` (`"static"` ou `"dynamic"`). As informações da conta do recebedor devem trazer exatamente uma chave ou uma `url` (verificada com a mesma regra de localização de PSP do `generatePixPayload`), e o objeto "Point of Initiation Method" (`01`) precisa ser coerente com isso: uma chave pertence a um payload estático (`01` ausente ou `"11"`) e uma `url` a um dinâmico (`01` igual a `"12"`), então qualquer outra combinação retorna `null`. Um payload estático que informa um valor precisa informar um valor maior que zero (`54` igual a `0.00` é reservado ao BR Code de Pix Saque/Troco, que está fora de escopo), e em um payload dinâmico o valor e o `txid` são ignorados, como o manual determina. Payloads cuja localização fica em um Unreserved Template (IDs 80 a 99, Pix Automático) estão fora de escopo e retornam `null`. +Interpreta um payload de BR Code Pix e retorna seus campos. O payload é validado pelo `isValidPixPayload` primeiro, então uma estrutura malformada, um CRC quebrado ou um objeto obrigatório ausente retornam `null` em vez de um resultado parcial. Um payload estático vem com `key`, um dinâmico com `url`. A chave Pix em si não é validada, já que o manual permite um QR Code estático construído com uma chave que não existe mais no DICT; a titularidade da chave só é resolvida no momento do pagamento. O "Additional Data Field Template" (ID 62) é obrigatório na tabela do BR Code mas opcional na especificação EMV® a que ela se refere, então é aceito quando ausente. Os tamanhos que o manual reserva para o nome do recebedor (25), a cidade do recebedor (15), o `txid` (25) e o campo 26-01 da chave Pix (77) são limites do lado do gerador, aplicados por `generatePixPayload` e não verificados aqui, já que payloads reais os ultrapassam com frequência. O resultado é tipado como `PixPayloadInfo`; `pointOfInitiation` está sempre presente e é tipado como `PixPointOfInitiation`, `"dynamic"` quando o payload traz uma localização de PSP ou quando o objeto "Point of Initiation Method" (`01`) é `"12"`, e `"static"` nos demais casos. As informações da conta do recebedor devem trazer exatamente um entre uma chave e uma `url` (verificada com a mesma regra de localização de PSP do `generatePixPayload`); o próprio `01` é informativo, então pode estar ausente em qualquer um dos formatos e apenas um valor fora de `{"11", "12"}` retorna `null`. Quando um payload construído em torno de uma chave traz um valor, esse valor precisa ser maior que zero, a menos que o payload seja um BR Code de Pix Saque: o §2.6 do manual do Pix coloca o ISPB do facilitador de serviço de saque no subobjeto 26-03 (`fss`), devolvido como `withdrawalFacilitator`, e `54` igual a `"0"` ou `"0.00"` é aceito junto dele. Rejeitar um valor zero sem o `fss` é uma restrição deliberada desta biblioteca, não uma regra do manual. Um `fss` escrito ao lado de uma localização de PSP retorna `null`: o §2.7 do Manual de Padrões para Iniciação do Pix mapeia o QR Code dinâmico para exatamente dois subobjetos, `00` (GUI) e `25` (URL), e o `fss` pertence ao template estático do §2.6. Quando o payload traz uma localização de PSP, o valor e o `txid` são ignorados, como o manual determina. Os Unreserved Templates (IDs 80 a 99) são ignorados: um "QR Code composto" do Pix Automático que também traga uma localização de pagamento em 26-25 é interpretado como um payload dinâmico comum e sua localização de recorrência é descartada, então quem precisa distinguir os dois não pode se apoiar neste parser. Só um payload sem nenhum template Pix nos IDs 26 a 51 retorna `null`. ```javascript -import { parsePixPayload } from '@brazilian-utils/brazilian-utils'; +import { getPixPayloadInfo } from '@brazilian-utils/brazilian-utils'; -parsePixPayload( +getPixPayloadInfo( '00020126580014br.gov.bcb.pix0136123e4567-e12b-12d1-a456-426655440000' + '5204000053039865802BR5913Fulano de Tal6008BRASILIA62070503***63041D3D' ); // { -// key: '123e4567-e12b-12d1-a456-426655440000', // merchantName: 'Fulano de Tal', -// merchantCity: 'BRASILIA' +// merchantCity: 'BRASILIA', +// pointOfInitiation: 'static', +// key: '123e4567-e12b-12d1-a456-426655440000' // } ``` ## generatePixPayload -Gera o payload de um BR Code Pix. Exatamente um entre `params.key` e `params.url` deve ser informado (parte de `GeneratePixPayloadParams`); `null` é retornado quando ambos ou nenhum são informados. `url` deve ser uma localização de PSP como o manual do Bacen define: um host com caminho, sem esquema (`pix.example.com/qr/v2/1234`); um payload dinâmico não pode carregar `amount` nem `txid`, que pertencem à localização do PSP, e um `amount` que arredonda para `0.00` é rejeitado. +Gera o payload de um BR Code Pix. Exatamente um entre `params.key` e `params.url` deve ser informado (parte de `GeneratePixPayloadOptions`); `null` é retornado quando ambos ou nenhum são informados. `url` deve ser uma localização de PSP como o manual do Bacen define: um host com caminho, sem esquema (`pix.example.com/qr/v2/1234`); um payload dinâmico não pode carregar `amount` nem `txid`, que pertencem à localização do PSP. O valor é escrito com as duas casas decimais que o BR Code aceita, então tanto um que arredonda para `0.00` quanto um que não sobrevive a esse round-trip (`0.005`, `123.456`) são rejeitados, em vez de escritos como uma quantia diferente. O BR Code de Pix Saque, que anuncia o `fss` do subobjeto 26-03, é interpretado pelo `getPixPayloadInfo`, mas não é gerado aqui. -Quando `params.key` é informado, ela é normalizada para a forma canônica do DICT pelo `parsePixKey` e o payload é estático. Quando `params.url` é informado no lugar (a localização do PSP, sem o esquema da URL, ex.: `"pix.example.com/qr/v2/1234"`), o payload é dinâmico conforme o Manual de Padrões para Iniciação do Pix: a URL ocupa o lugar da chave no template "Merchant Account Information" e o objeto "Point of Initiation Method" é definido como dinâmico (`12`); `params.url` pode ter no máximo 77 caracteres. `merchantName`, `merchantCity` e `description` são convertidos para ASCII imprimível (acentos removidos) e truncados ao que o BR Code permite. O `parsePixPayload` já interpreta os dois formatos, então `parsePixPayload(generatePixPayload({ url, ... }))` forma um round-trip. +Quando `params.key` é informado, ela é normalizada para a forma canônica do DICT pelo `getPixKeyInfo` e o payload é estático. Quando `params.url` é informado no lugar (a localização do PSP, sem o esquema da URL, ex.: `"pix.example.com/qr/v2/1234"`), o payload é dinâmico conforme o Manual de Padrões para Iniciação do Pix: a URL ocupa o lugar da chave no template "Merchant Account Information" e o objeto "Point of Initiation Method" é definido como dinâmico (`12`); `params.url` pode ter no máximo 77 caracteres. `merchantName`, `merchantCity` e `description` são convertidos para ASCII imprimível (acentos removidos) e truncados ao que o BR Code permite. O `getPixPayloadInfo` já interpreta os dois formatos, então `getPixPayloadInfo(generatePixPayload({ url, ... }))` forma um round-trip. ```javascript import { generatePixPayload } from '@brazilian-utils/brazilian-utils'; @@ -269,46 +274,75 @@ generatePixPayload({ merchantName: 'Fulano', merchantCity: 'Brasília' }); // nu ## isValidNfeKey -Valida se uma chave de acesso de DF-e (Documento Fiscal eletrônico) é válida. Cobre todos os documentos que compartilham o mesmo layout de 44 dígitos: NF-e (modelo 55), NFC-e (modelo 65), CT-e (modelo 57), MDF-e (modelo 58) e CT-e OS (modelo 67, o Conhecimento de Transporte Eletrônico para Outros Serviços do [Ajuste SINIEF 09/07](https://www.confaz.fazenda.gov.br/legislacao/ajustes/2007/aj_009_07)). Aceita espaços entre os grupos de dígitos (a máscara de exibição usual) e o prefixo `NFe` encontrado no atributo `Id` do XML do documento. A forma de emissão (`tpEmis`) precisa ser um dos códigos atribuídos pelo MOC, de 1 a 7 ou 9; o 8 não é atribuído e torna a chave inválida. +Valida se uma chave de acesso de DF-e (Documento Fiscal eletrônico) é válida. Cobre todos os documentos cuja chave de acesso é a mesma string de 44 dígitos: NF-e (modelo 55), NFC-e (65), CT-e (57, o Conhecimento de Transporte Eletrônico instituído pela cláusula primeira do [Ajuste SINIEF 09/07](https://www.confaz.fazenda.gov.br/legislacao/ajustes/2007/AJ_009_07)), MDF-e (58), CT-e OS (67, o Conhecimento de Transporte Eletrônico para Outros Serviços instituído pela cláusula primeira do [Ajuste SINIEF 36/19](https://www.confaz.fazenda.gov.br/legislacao/ajustes/2019/AJ036_19)), GTV-e (64, o CT-e Guia de Transporte de Valores instituído pela cláusula primeira do [Ajuste SINIEF 03/20](https://www.confaz.fazenda.gov.br/legislacao/ajustes/2020/ajuste-sinief-03-20)), BP-e (63), NF3e (66) e NFCom (62). O CF-e-SAT (59) fica de fora: sua "chave de consulta" de 44 posições é composta de outro jeito. Os 44 dígitos podem ser separados nos grupos impressos de 4 por espaço em branco, `.`, `-` ou `/`, inclusive uma sequência deles entre dois grupos, a mesma máscara intercambiável que `isValidCpf` e `isValidCnpj` aceitam; um separador dentro de um grupo de 4, ou qualquer outro caractere, é rejeitado em vez de removido. Os prefixos `NFe`, `CTe`, `MDFe`, `BPe`, `NF3e` e `NFCom` encontrados no atributo `Id` do XML do documento são removidos antes dessa verificação, junto com qualquer espaço em branco entre o prefixo e o primeiro grupo. + +O tipo de emissão (`tpEmis`) é conferido contra os códigos que o MOC daquele modelo atribui, então o conjunto aceito muda com o modelo: de 1 a 7 e 9 para NF-e e NFC-e, `{1, 3, 4, 5, 7, 8}` para o CT-e, `{1, 5, 7, 8}` para o CT-e OS, `{1, 2, 7, 8}` para a GTV-e, `{1, 2, 3}` para o MDF-e e `{1, 2}` para o BP-e, a NF3e e a NFCom. O código 8, a autorização pela SVC-SP, é atribuído somente pelo [MOC do CT-e 4.00](https://dfe-portal.svrs.rs.gov.br/CTE/Documentos), nunca pelo da NF-e; os domínios do [BP-e](https://dfe-portal.svrs.rs.gov.br/BPE/Documentos), da [NF3e](https://dfe-portal.svrs.rs.gov.br/NF3e/Documentos) e da [NFCom](https://dfe-portal.svrs.rs.gov.br/NFCOM/Documentos) vêm dos manuais deles. Para NF-e e NFC-e o código numérico também é conferido contra a regra B03-10 do MOC da NF-e, que proíbe os vinte valores repetidos e sequenciais de `cNF` que ela lista e um `cNF` igual ao número do documento. Já um número de documento todo zerado é recusado em todos os modelos seguindo o leiaute, não por escolha desta biblioteca: o `tiposBasico_v4.00.xsd` do [pacote de schemas da NF-e](https://dfe-portal.svrs.rs.gov.br/NFE/Documentos) tipa o `nNF` como `TNF`, cujo pattern é `[1-9]{1}[0-9]{0,8}`, e o Anexo I de cada um dos outros modelos repete o mesmo regex no seu próprio campo de número. ```javascript import { isValidNfeKey } from '@brazilian-utils/brazilian-utils'; isValidNfeKey('35170458716523000119550010000000121000123458'); // true (NF-e, SP) isValidNfeKey('NFe35170458716523000119550010000000121000123458'); // true (prefixo Id do XML) +isValidNfeKey('CTe35170458716523000119570010000000128000123452'); // true (CT-e autorizado pela SVC-SP) isValidNfeKey('3517 0458 7165 2300 0119 5500 1000 0000 1210 0012 3458'); // true (com máscara) +isValidNfeKey('3517.0458.7165.2300.0119.5500.1000.0000.1210.0012.3458'); // true (qualquer um dos caracteres de máscara) +isValidNfeKey('351 70458716523000119550010000000121000123458'); // false (separador dentro de um grupo de 4) isValidNfeKey('99170458716523000119550010000000121000123458'); // false (cUF inválido) -isValidNfeKey('35170458716523000119550010000000128000123455'); // false (tpEmis 8 não é atribuído) +isValidNfeKey('35170458716523000119550010000000128000123455'); // false (o MOC da NF-e não atribui tpEmis 8) +isValidNfeKey('35170458716523000119550010000000121000000003'); // false (cNF 00000000, regra B03-10) ``` ## formatNfeKey -Formata uma chave de acesso de DF-e (NF-e, NFC-e, CT-e, MDF-e ou CT-e OS) em grupos de 4 dígitos separados por espaço, a forma de exibição usual impressa na DANFE. +Formata uma chave de acesso de DF-e (Documento Fiscal eletrônico) em grupos de 4 dígitos separados por espaço, a forma em que todo documento auxiliar a imprime: o DANFE da NF-e e da NFC-e, o DACTE do CT-e, do CT-e OS e da GTV-e, o DAMDFE do MDF-e, o DABPE do BP-e, o DANF3E da NF3e e o DANFE-COM da NFCom. Como todo formatador deste pacote, o valor é lido pelos seus dígitos e agrupado até onde eles vão, então uma chave com máscara ou parcial, ainda sendo digitada, é agrupada progressivamente, e qualquer coisa sem dígito (um objeto, `true`, um objeto criado com `Object.create(null)`) devolve `''` em vez de lançar. Use `isValidNfeKey` para verificar uma chave. O `options.pad` (parte de `FormatNfeKeyOptions`) preenche o valor com zeros à esquerda até os 44 dígitos de uma chave de acesso completa (padrão `false`). O parâmetro é tipado como string porque 44 dígitos são mais do que um número JavaScript comporta com exatidão; em tempo de execução um número é lido como a string dos seus dígitos, como em todo formatador deste pacote. ```javascript import { formatNfeKey } from '@brazilian-utils/brazilian-utils'; formatNfeKey('35170458716523000119550010000000121000123458'); // '3517 0458 7165 2300 0119 5500 1000 0000 1210 0012 3458' + +formatNfeKey('12345'); // '1234 5' + +formatNfeKey('12345', { pad: true }); +// '0000 0000 0000 0000 0000 0000 0000 0000 0000 0001 2345' ``` ## parseNfeKey -Interpreta uma chave de acesso de DF-e e retorna seus campos (state, year, month, taxId, model, series, number, emissionType, code, checkDigit). Aceita as mesmas formas de entrada do `isValidNfeKey` e retorna `null` quando a chave não é válida. O resultado é tipado como `NfeKey`. +Remove a formatação de uma chave de acesso de DF-e, mantém apenas os dígitos e limita o resultado a 44 dígitos. Os prefixos `NFe`, `CTe`, `MDFe`, `BPe`, `NF3e` e `NFCom` que o atributo `Id` do XML do documento coloca antes da chave são retirados primeiro, já que o `NF3e` carrega um dígito próprio; use `isValidNfeKey` para verificar a chave e `getNfeKeyInfo` para ler os campos dela. ```javascript import { parseNfeKey } from '@brazilian-utils/brazilian-utils'; -parseNfeKey('35170458716523000119550010000000121000123458'); -// { state: 'SP', year: 2017, month: 4, taxId: '58716523000119', model: '55', +parseNfeKey('3517 0458 7165 2300 0119 5500 1000 0000 1210 0012 3458'); +// '35170458716523000119550010000000121000123458' + +parseNfeKey('NFe35170458716523000119550010000000121000123458'); +// '35170458716523000119550010000000121000123458' +``` + +## getNfeKeyInfo + +Interpreta uma chave de acesso de DF-e e retorna seus campos (stateCode, year, month, taxId, model, series, number, emissionType, code, checkDigit). Aceita as mesmas formas de entrada do `isValidNfeKey` e retorna `null` quando a chave não é válida. O resultado é tipado como `NfeKeyInfo`, cujo `model` é um `NfeKeyModel`. A NFCom (`'62'`) e a NF3e (`'66'`) gastam a posição 36 da chave com o `nSiteAutoriz`, o site do autorizador que recebeu o documento, então para esses dois modelos o resultado também traz `authorizationSite` e o `code` tem 7 dígitos em vez de 8. + +```javascript +import { getNfeKeyInfo } from '@brazilian-utils/brazilian-utils'; + +getNfeKeyInfo('35170458716523000119550010000000121000123458'); +// { stateCode: 'SP', year: 2017, month: 4, taxId: '58716523000119', model: '55', // series: 1, number: 12, emissionType: 1, code: '00012345', checkDigit: 8 } -parseNfeKey('invalid'); // null +getNfeKeyInfo('35170458716523000119620010000000121000123450'); +// { stateCode: 'SP', year: 2017, month: 4, taxId: '58716523000119', model: '62', +// series: 1, number: 12, emissionType: 1, code: '0012345', checkDigit: 0, authorizationSite: 0 } + +getNfeKeyInfo('invalid'); // null ``` ## isValidEmail -Valida se email é válido. +Valida se email é válido. O conjunto aceito é um subconjunto prático da definição de [endereço de e-mail válido](https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) do HTML da WHATWG, e não da [RFC 5322](https://www.rfc-editor.org/rfc/rfc5322). A parte local é limitada a letras, dígitos e `_'+-.`, e não pode começar com ponto, terminar com ponto ou apóstrofo, nem conter dois pontos seguidos. O domínio precisa ter pelo menos um ponto, e cada rótulo separado por ponto segue a produção `[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?` da WHATWG, então um rótulo não pode começar nem terminar com hífen nem passar de 63 caracteres; o rótulo final é alfabético e tem de 2 a 63 letras, então `user@example.c1` é rejeitado. Partes locais entre aspas (`"john doe"@example.com`) e literais de endereço (`john@[127.0.0.1]`) são rejeitadas. ```javascript import { isValidEmail } from '@brazilian-utils/brazilian-utils'; @@ -318,12 +352,14 @@ isValidEmail('john.doe@hotmail.com'); // true ## isValidPhone -Valida se o número de telefone (celular ou residencial) é válido. Um código de país brasileiro (`+55`, `0055` ou um `55` isolado) é aceito e removido antes da validação, seguindo a regra documentada em `parsePhone`. `options.accept` (tipado como `PhoneType[]`, parte de `IsValidPhoneOptions`) define quais tipos de número são aceitos e tem como padrão `['mobile', 'landline']`; adicione `'service'` para também aceitar os números não geográficos reconhecidos por `isValidServicePhone`, ou informe `[]` para não aceitar nenhum. +Valida se o número de telefone (celular ou fixo) é válido. Um código de país brasileiro (`+55`, `0055` ou um `55` isolado) é aceito e removido antes da validação, seguindo a regra documentada em `parsePhone`. `options.accept` (tipado como `PhoneType[]`, parte de `IsValidPhoneOptions`) define quais tipos de número são aceitos e tem como padrão `['mobile', 'landline']`; adicione `'service'` para também aceitar os números não geográficos reconhecidos por `isValidServicePhone`, ou informe `[]` para não aceitar nenhum. `options.version` (tipado como `PhoneVersion`, parte do mesmo tipo) é repassado ao `isValidMobilePhone` e escolhe qual regra de numeração celular é aplicada: `1` (padrão) o formato antigo, cujo primeiro dígito do número pode ser 6, 7, 8 ou 9, e `2` o atual, da Resolução Anatel 749/2022, art. 12, I, "a", que aceita 7, 8 ou 9 e rejeita o prefixo `700`. Vale apenas para celulares; números fixos e de serviço não são afetados. ```javascript import { isValidPhone } from '@brazilian-utils/brazilian-utils'; isValidPhone('11900000000'); // true +isValidPhone('11712345678', { version: 2 }); // true (7, 8 e 9 são todos SMP) +isValidPhone('11700123456', { version: 2 }); // false (a série 700 é de satélite) isValidPhone('+55 11 98765-4321'); // true (código de país aceito) isValidPhone('08001234567'); // false (números de serviço não são aceitos por padrão) isValidPhone('08001234567', { accept: ['service'] }); // true @@ -332,7 +368,7 @@ isValidPhone('11900000000', { accept: [] }); // false ## formatPhone -Formata número de telefone de acordo com padrões brasileiros. `options.mask` (tipado como `PhoneMask`) aceita `"sn"` (padrão, apenas o número assinante, 9 dígitos, sem DDD), `"nanp"` (DDD + número assinante, 11 dígitos), `"e164"` (`"+5511987654321"`), `"international"` (`"+55 11 98765-4321"`, a forma como um número brasileiro é exibido para quem liga do exterior), `"service"` (`"0800 123 4567"` ou `"4004-1234"`, os agrupamentos convencionais para números de serviço) ou `"auto"`. O `"auto"` usa `"international"` quando `value` traz um código de país brasileiro (`+55`, `0055` ou um `55` seguido de 10 ou 11 dígitos), `"service"` quando `value` é um número de serviço e, nos demais casos, decide pela quantidade de dígitos: `"nanp"` quando `value` tem mais dígitos que um número assinante isolado, `"sn"` quando não tem. `"e164"` e `"international"` removem antes o código de país (regra documentada em `parsePhone`) e recaem para a apresentação `"service"` no caso de um número de serviço, já que esses não têm forma E.164. Se `value` incluir o DDD, informe `{ mask: 'auto' }` (ou `'nanp'`) explicitamente, já que a máscara padrão `"sn"` assume que não há DDD e trunca silenciosamente um DDD presente. +Formata número de telefone de acordo com padrões brasileiros. `options.mask` (tipado como `PhoneMask`) aceita `"sn"` (padrão, apenas o número assinante, 9 dígitos, sem DDD), `"nanp"` (DDD + número assinante, `"(00) 00000-0000"` para os 11 dígitos de um celular e `"(00) 0000-0000"` para os 10 dígitos de um fixo, mantendo o agrupamento de 11 dígitos em qualquer outro tamanho), `"e164"` (`"+5511987654321"`), `"international"` (`"+55 11 98765-4321"`, a forma como um número brasileiro é exibido para quem liga do exterior), `"service"` (`"0800 123 4567"` ou `"4004-1234"`, os agrupamentos convencionais para números de serviço) ou `"auto"`. O `"auto"` usa `"international"` quando `value` traz um código de país brasileiro (`+55`, `0055` ou um `55` seguido de 10 ou 11 dígitos), `"service"` quando `value` é um número de serviço e, nos demais casos, decide pela quantidade de dígitos: `"nanp"` quando `value` tem mais dígitos que um número assinante isolado, `"sn"` quando não tem. `"e164"` e `"international"` removem antes o código de país (regra documentada em `parsePhone`) e recaem para a apresentação `"service"` no caso de um número de serviço, já que esses não têm forma E.164. Se `value` incluir o DDD, informe `{ mask: 'auto' }` (ou `'nanp'`) explicitamente, já que a máscara padrão `"sn"` assume que não há DDD e trunca silenciosamente um DDD presente. Uma `mask` fora da união recai para o padrão `"sn"` em vez de lançar erro. ```javascript import { formatPhone } from '@brazilian-utils/brazilian-utils'; @@ -340,6 +376,8 @@ import { formatPhone } from '@brazilian-utils/brazilian-utils'; formatPhone('987654321'); // 98765-4321 (padrão "sn", sem DDD) formatPhone('11900000000', { mask: 'nanp' }); // (11) 90000-0000 formatPhone('11900000000', { mask: 'auto' }); // (11) 90000-0000 +formatPhone('1130000000', { mask: 'nanp' }); // (11) 3000-0000 (fixo de 10 dígitos) +formatPhone('1130000000', { mask: 'auto' }); // (11) 3000-0000 (fixo de 10 dígitos) formatPhone('11987654321', { mask: 'e164' }); // +5511987654321 formatPhone('+5511987654321', { mask: 'international' }); // +55 11 98765-4321 formatPhone('08001234567', { mask: 'service' }); // 0800 123 4567 @@ -363,19 +401,21 @@ parsePhone('55987654321'); // 55987654321 (DDD 55, não confundido com o código ## isValidMobilePhone -Valida se o número de telefone celular é válido. `options.version` (tipado como `PhoneVersion`) controla qual regra de numeração celular é aplicada: `1` (padrão) é o formato anterior à Resolução Anatel 749/2022, mantido por compatibilidade com a 2.3.0, cujo primeiro dígito do número (após o DDD) pode ser 6, 7, 8 ou 9; `2` exige apenas 9, um subconjunto mais restrito do art. 12 I da resolução (Serviço Móvel Pessoal). +Valida se o número de telefone celular é válido. `options.version` (tipado como `PhoneVersion`) controla qual regra de numeração celular é aplicada: `1` (padrão) é o formato anterior à Resolução Anatel 749/2022, mantido por compatibilidade com a 2.3.0, cujo primeiro dígito do número (após o DDD) pode ser 6, 7, 8 ou 9; `2` aplica o art. 12, I, "a" da resolução, que coloca 7, 8 e 9 no Serviço Móvel Pessoal (SMP), então um 6 inicial é Reserva Técnica e é rejeitado. A versão `2` também exclui o prefixo `700`, que o art. 12, II reserva ao Serviço Móvel Global por Satélite e não ao SMP, então `isValidMobilePhone('11700123456', { version: 2 })` é `false`; a versão `1` não o exclui e o aceita. ```javascript import { isValidMobilePhone } from '@brazilian-utils/brazilian-utils'; isValidMobilePhone('11900000000'); // true isValidMobilePhone('11712345678', { version: 1 }); // true (formato antigo) -isValidMobilePhone('11712345678', { version: 2 }); // false (v2 exige 9 como primeiro dígito) +isValidMobilePhone('11712345678', { version: 2 }); // true (7 também é SMP) +isValidMobilePhone('11612345678', { version: 2 }); // false (6 é Reserva Técnica) +isValidMobilePhone('11700123456', { version: 2 }); // false (a série 700 é de satélite) ``` ## isValidLandlinePhone -Valida se o número de telefone residencial é válido. +Valida se o número de telefone fixo é válido. ```javascript import { isValidLandlinePhone } from '@brazilian-utils/brazilian-utils'; @@ -385,7 +425,7 @@ isValidLandlinePhone('1130000000'); // true ## isValidServicePhone -Valida se um número de telefone é um número de serviço brasileiro válido, discado sem DDD: os Códigos Não Geográficos `0300`, `0303`, `0500`, `0800` e `0900` (11 dígitos no total), os números abreviados `300X`/`400X` (8 dígitos), e os códigos de 3 dígitos dos Códigos de Acesso a Serviços de Utilidade Pública designados pela Anatel (ex.: `190`, `192`; `112` e `911` também são aceitos, como aliases exclusivos de celular do `190` que a Anatel lista junto aos demais códigos de 3 dígitos). Apenas a estrutura é verificada, o número não precisa estar atribuído a ninguém. +Valida se um número de telefone é um número de serviço brasileiro válido, discado sem DDD: os Códigos Não Geográficos `0300`, `0303`, `0500`, `0800` e `0900` (11 dígitos no total, então a forma curta e extinta de `0800` + 6 dígitos é rejeitada), os números abreviados `300X`/`400X` (8 dígitos), e os códigos de 3 dígitos dos Códigos de Acesso a Serviços de Utilidade Pública designados pela Anatel (ex.: `190`, `192`), cuja tabela consolidada é o Anexo do [Ato Anatel nº 43.151/2004](https://informacoes.anatel.gov.br/legislacao/atos-de-numeracao/2004/1648-ato-43151). O `112` e o `911` são rejeitados: a Anatel não designa nenhum dos dois, e o `911` sequer está dentro da faixa `1N₂N₁` que o art. 13 da [Resolução nº 749/2022](https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749) destina aos serviços de utilidade pública, então o encaminhamento deles nos aparelhos é uma convenção GSM, não uma designação de numeração. Apenas a estrutura é verificada: o número não precisa estar atribuído a ninguém, e a regra do `0500` que codifica o valor da doação nos dois últimos dígitos não é aplicada. A Anatel retirou os códigos de 4 dígitos em vez de alocá-los (o art. 43 I da [Resolução nº 86/1998](https://informacoes.anatel.gov.br/legislacao/resolucoes/1998/336-resolucao-86) e o art. 2º II do Ato acima mandaram liberá-los), então apenas as raízes convencionais `300X` e `400X` são reconhecidas: outros prefixos de "Número Único" usados no mercado, como `4020` e `4062`, estão fora de escopo e são rejeitados. ```javascript import { isValidServicePhone } from '@brazilian-utils/brazilian-utils'; @@ -400,19 +440,19 @@ isValidServicePhone('11987654321'); // false (número geográfico) Retorna o estado (e a região) a que um DDD brasileiro pertence, dentre os 67 DDDs em uso no Plano Geral de Numeração da Anatel. Aceita string ou número inteiro não negativo, removendo caracteres não numéricos antes de comparar. Exporta o tipo `AreaCodeInfo`. -`stateCode` é sempre um único estado: aquele que concentra quase todos os municípios do DDD. Quatro DDDs cruzam a divisa de um estado, e para esses o `stateCodes` lista também os demais. O DDD 61 é o mais amplo deles: atende o Distrito Federal e os doze municípios goianos do Entorno do Distrito Federal (Águas Lindas de Goiás, Cabeceiras, Cidade Ocidental, Cristalina, Formosa, Luziânia, Novo Gama, Padre Bernardo, Planaltina, Santo Antônio do Descoberto, Valparaíso de Goiás e Vila Boa). Os outros três são o 42, compartilhado entre o Paraná e Porto União (SC), o 47, entre Santa Catarina e Rio Negro (PR), e o 49, entre Santa Catarina e Barracão (PR). +`stateCode` é sempre um único estado: a sede do DDD, o estado da cidade em torno da qual o código foi alocado, que não é necessariamente o estado que concentra a maioria dos seus municípios. Quatro DDDs cruzam a divisa de um estado, e para esses o `stateCodes` lista também os demais. O DDD 61 é o mais amplo deles: atende o Distrito Federal e os doze municípios goianos do Entorno do Distrito Federal (Águas Lindas de Goiás, Cabeceiras, Cidade Ocidental, Cristalina, Formosa, Luziânia, Novo Gama, Padre Bernardo, Planaltina, Santo Antônio do Descoberto, Valparaíso de Goiás e Vila Boa), então seu `stateCode` é `'DF'` mesmo o Distrito Federal tendo apenas um dos seus treze municípios, Brasília. Os outros três são o 42, compartilhado entre o Paraná e Porto União (SC), o 47, entre Santa Catarina e Rio Negro (PR), e o 49, entre Santa Catarina e Barracão (PR), e neles a sede realmente concentra todos os municípios menos o citado. ```javascript import { getAreaCodeInfo } from '@brazilian-utils/brazilian-utils'; getAreaCodeInfo('11'); -// { areaCode: 11, stateCode: 'SP', stateName: 'São Paulo', region: 'Sudeste', stateCodes: ['SP'] } +// { areaCode: 11, stateCode: 'SP', stateName: 'São Paulo', regionCode: 'SE', regionName: 'Sudeste', stateCodes: ['SP'] } getAreaCodeInfo(21); -// { areaCode: 21, stateCode: 'RJ', stateName: 'Rio de Janeiro', region: 'Sudeste', stateCodes: ['RJ'] } +// { areaCode: 21, stateCode: 'RJ', stateName: 'Rio de Janeiro', regionCode: 'SE', regionName: 'Sudeste', stateCodes: ['RJ'] } getAreaCodeInfo('61'); -// { areaCode: 61, stateCode: 'DF', stateName: 'Distrito Federal', region: 'Centro-Oeste', stateCodes: ['DF', 'GO'] } +// { areaCode: 61, stateCode: 'DF', stateName: 'Distrito Federal', regionCode: 'CO', regionName: 'Centro-Oeste', stateCodes: ['DF', 'GO'] } getAreaCodeInfo('00'); // null getAreaCodeInfo(-11); // null @@ -453,14 +493,27 @@ isValidLicensePlate('ABC1234EXTRA'); // false (caracteres em excesso) ## isValidRenavam -Valida se o RENAVAM (Registro Nacional de Veículos Automotores) é válido. Suporta tanto o formato antigo (9 dígitos) quanto o novo formato (11 dígitos). +Valida se o RENAVAM (Registro Nacional de Veículos Automotores) é válido. Suporta tanto o formato antigo (9 dígitos) quanto o novo formato (11 dígitos). Espaços, pontos e hífens ao redor/entre os dígitos são ignorados, mas qualquer outro caractere, uma letra em especial, invalida o valor. Um registro com todos os dígitos iguais também é rejeitado. ```javascript import { isValidRenavam } from '@brazilian-utils/brazilian-utils'; isValidRenavam('639884962'); // true (9 dígitos, formato antigo) isValidRenavam('00639884962'); // true (11 dígitos, formato novo) +isValidRenavam('0063988.4962'); // true (pontos e hífens são ignorados) isValidRenavam('12345678901'); // false (checksum inválido) +isValidRenavam('00000000000'); // false (dígitos repetidos) +isValidRenavam('ab00639884962'); // false (letras são rejeitadas) +``` + +## generateRenavam + +Gera um RENAVAM válido aleatório: o formato de 11 dígitos, dez dígitos de base mais o dígito verificador. Uma base com todos os dígitos iguais é sorteada de novo, já que `isValidRenavam` rejeita essas. Usa `Math.random()` internamente, então não é criptograficamente seguro. + +```javascript +import { generateRenavam } from '@brazilian-utils/brazilian-utils'; + +generateRenavam(); // '12345678900' ``` ## isValidPis @@ -475,7 +528,7 @@ isValidPis('12056412547'); // false ## formatPis -Formata número de PIS. +Formata número de PIS. `options.pad` (parte de `FormatPisOptions`) completa o valor com zeros à esquerda até os 11 dígitos antes de aplicar a máscara (padrão `false`). ```javascript import { formatPis } from '@brazilian-utils/brazilian-utils'; @@ -496,12 +549,13 @@ parsePis('123.45678.90-1'); // 12345678901 ## formatCep -Formata o CEP. +Formata o CEP ([código de endereçamento postal](https://pt.wikipedia.org/wiki/C%C3%B3digo_de_Endere%C3%A7amento_Postal)). `options.pad` (parte de `FormatCepOptions`) completa o valor com zeros à esquerda até os 8 dígitos antes de aplicar a máscara (padrão `false`); um CEP que começa com `0` passado como número perde esse zero, então passe-o como string ou use `pad`. ```javascript import { formatCep } from '@brazilian-utils/brazilian-utils'; formatCep('92500000'); // 92500-000 +formatCep('9250000', { pad: true }); // 09250-000 ``` ## parseCep @@ -516,7 +570,7 @@ parseCep('92500-000'); // 92500000 ## getAddressInfoByCep -Busca informações de endereço para um CEP usando múltiplos provedores. O padrão é `['viacep', 'brasilapi']`. O provedor `'widenet'` está descontinuado (seu endpoint não responde mais) e foi excluído da lista padrão, mas ainda pode ser solicitado explicitamente via `options.providers` (tipado como `CepProvider[]`). O endereço retornado é tipado como `AddressInfo`. +Busca informações de endereço para um CEP usando múltiplos provedores. O padrão é `['viacep', 'brasilapi']`. O provedor `'widenet'` está descontinuado (seu endpoint não responde mais) e foi excluído da lista padrão, mas ainda pode ser solicitado explicitamente via `options.providers` (tipado como `CepProvider[]`). O endereço retornado é tipado como `AddressInfo`. Uma falha transitória de rede é repetida duas vezes por provedor, com backoff linear de 250 ms (250 ms e depois 500 ms), então um provedor que continua falhando é tentado até 3 vezes e acrescenta cerca de 750 ms antes de a sua própria falha se concretizar; um status de erro HTTP ou uma falha não recuperável não é repetida. Os provedores são disparados juntos e disputados com `Promise.any`, não consultados um após o outro, então essas tentativas não atrasam nada para os demais provedores, apenas o momento em que uma rejeição por falha de todos pode aparecer. Um `options.providers` que não nomeia nenhum provedor conhecido rejeita com `GetAddressInfoByCepValidationError` ("Nenhum provedor válido especificado"): um array vazio, um array de nomes desconhecidos e um valor que não é um array, incluindo `null`. Com `providers: ['brasilapi']`, um CEP que a BrasilAPI não conhece rejeita com `GetAddressInfoByCepNotFoundError`, já que a BrasilAPI sinaliza a ausência com HTTP 404; qualquer outro status de erro continua sendo um `GetAddressInfoByCepServiceError`. Os três estendem `GetAddressInfoByCepError`, a classe base de todos os erros com que este utilitário rejeita, então um único `catch` nela cobre todos. ```javascript import { getAddressInfoByCep } from '@brazilian-utils/brazilian-utils'; @@ -526,32 +580,37 @@ const address = await getAddressInfoByCep('01310100'); // { cep: '01310100', state: 'SP', city: 'São Paulo', neighborhood: 'Bela Vista', street: 'Avenida Paulista' } // Usando provedores específicos -const address = await getAddressInfoByCep('01310-100', { +const addressFromProviders = await getAddressInfoByCep('01310-100', { providers: ['viacep', 'brasilapi'] }); // Usando número como entrada (será preenchido automaticamente com zeros à esquerda) -const address = await getAddressInfoByCep(1310100); +const addressFromNumber = await getAddressInfoByCep(1310100); ``` ## isValidProcessoJuridico -Valida o número do processo jurídico de acordo com definição do [CNJ](https://atos.cnj.jus.br/atos/detalhar/119). +Valida o número do processo jurídico de acordo com definição do [CNJ](https://atos.cnj.jus.br/atos/detalhar/119): o layout `NNNNNNN-DD.AAAA.J.TR.OOOO`, os dígitos verificadores `DD` e o par `J`/`TR`, que precisa identificar um órgão e um tribunal existentes nas listas fechadas definidas pela Resolução CNJ nº 65/2008, de modo que um número com dígito verificador correto mas com um tribunal inexistente é rejeitado. As listas fechadas vêm do art. 1º, § 4º e § 5º da resolução, o § 5º, III na redação que a Resolução CNJ nº 477/2022 lhe deu para acomodar o TRF da 6ª Região. A unidade de origem (`OOOO`) é lida apenas como quatro dígitos, já que o art. 1º, § 6º deixa a codificação dela a cargo de cada tribunal e não publica lista central. Os separadores da máscara do CNJ (espaços, `.` e `-`) são aceitos entre os campos, e espaços em branco ao redor do valor são ignorados, mas qualquer outro caractere, uma letra em especial, invalida o valor. ```javascript import { isValidProcessoJuridico } from '@brazilian-utils/brazilian-utils'; isValidProcessoJuridico('00020802520125150049'); // true +isValidProcessoJuridico('0002080-25.2012.5.15.0049'); // true (máscara do CNJ) +isValidProcessoJuridico('0000100-68.2008.4.06.0000'); // true (TRF da 6ª Região) +isValidProcessoJuridico('0000100-23.2008.8.28.0000'); // false (não existe 28º Tribunal de Justiça) +isValidProcessoJuridico('ab00020802520125150049'); // false (letras são rejeitadas) ``` ## formatProcessoJuridico -Formata um número no formato definido pelo [CNJ](https://atos.cnj.jus.br/atos/detalhar/119) (máscara `NNNNNNN-DD.AAAA.J.TR.OOOO`). +Formata um número no formato definido pelo [CNJ](https://atos.cnj.jus.br/atos/detalhar/119) (máscara `NNNNNNN-DD.AAAA.J.TR.OOOO`). `options.pad` (parte de `FormatProcessoJuridicoOptions`) completa o valor com zeros à esquerda até os 20 dígitos antes de aplicar a máscara (padrão `false`). ```javascript import { formatProcessoJuridico } from '@brazilian-utils/brazilian-utils'; formatProcessoJuridico('00020802520125150049'); // 0002080-25.2012.5.15.0049 +formatProcessoJuridico('20802520125150049', { pad: true }); // 0002080-25.2012.5.15.0049 ``` ## parseProcessoJuridico @@ -566,7 +625,7 @@ parseProcessoJuridico('0002080-25.2012.5.15.0049'); // 00020802520125150049 ## isValidIe -Valida se a inscrição estadual de um estado é válida. A UF é case-insensitive. Regras notáveis por estado: GO aceita os prefixos `10`, `11` e `15`; PA aceita `15` e `75`-`79`; MS aceita `28` e `50`; SP tem o padrão de produtor rural `P0MMMSSSSD000`; TO usa códigos de tipo de 11 dígitos (`01`, `02`, `03`, `99`). +Valida se a inscrição estadual de um estado é válida. A UF é case-insensitive. Regras notáveis por estado: GO aceita os prefixos `10`, `11` e `15`; PA aceita `15` e `75`-`79`; MS aceita `28` e `50`; SP tem o padrão de produtor rural `P0MMMSSSSD000`; TO usa códigos de tipo de 11 dígitos (`01`, `02`, `03`, `99`). O TO também aceita uma forma de 9 dígitos, aplicando a mesma regra módulo 11 sobre os oito primeiros dígitos; a página do SINTEGRA documenta apenas a de 11 dígitos, então essa forma é comportamento da 2.3.0 mantido por compatibilidade, e não regra publicada. Uma inscrição só de zeros é aceita em todo estado cuja fórmula publicada produz dígito verificador 0 para ela (AM, BA com 8 ou 9 dígitos, CE, ES, MG, MT, PB, PE, PI, PR, RJ, RS, SC, SE, SP e TO com 9 dígitos), diferente de `isValidCpf` e `isValidCnpj`, que rejeitam dígitos repetidos. O AM entra nessa lista apenas pelo segundo ramo da fórmula publicada: o primeiro ramo da página, `Se Soma < 11 Então Dígito = 11 - Soma`, dá 11 para a inscrição só de zeros, enquanto o ramo `resto <= 1 ⇒ 0`, o implementado aqui, dá 0. ```javascript import { isValidIe } from '@brazilian-utils/brazilian-utils'; @@ -577,17 +636,17 @@ isValidIe('go', '109161793'); // true (case-insensitive) ## isValidBankAccount -Verifica se uma conta bancária brasileira é válida. O `bankCode` precisa estar na lista de participantes do STR publicada pelo Banco Central do Brasil (o mesmo dataset usado por `getBankByCode`), então um código não atribuído como `'999'` é sempre inválido. A partir daí o banco é validado de três formas: pelo algoritmo de dígito verificador publicado, apenas pela estrutura (o banco existe e a agência/conta respeitam a quantidade de dígitos documentada, para bancos que não publicam regra de dígito) ou pela verificação genérica mod10/mod11, que continua sendo o fallback para os demais bancos da lista. +Verifica se uma conta bancária brasileira é válida. O `bankCode` precisa estar na lista de participantes do STR publicada pelo Banco Central do Brasil (o mesmo dataset usado por `getBankByCode`), então um código não atribuído como `'999'` é sempre inválido. A partir daí o banco é validado de uma de três formas: pelo algoritmo de dígito verificador publicado, apenas pela estrutura (o banco existe e a agência/conta respeitam a quantidade de dígitos documentada, para bancos que não publicam regra de dígito) ou pela verificação genérica mod10/mod11, que continua sendo o fallback para os demais bancos da lista. Bancos validados pelo algoritmo de dígito verificador publicado: | Banco | Código | Agência | Conta | Observações | | --- | --- | --- | --- | --- | -| Banco do Brasil | `001` | 4-5 dígitos | 8-10 dígitos | mod11 com pesos 9..2; `digit` pode ser `"X"` | +| Banco do Brasil | `001` | 4-5 dígitos | 8-10 dígitos | mod11 com pesos 2..9 ciclando da direita para a esquerda; `digit` pode ser `"X"` | | Santander | `033` | 4 dígitos | 8 dígitos | pesos `9,7,3,1,0,0,9,7,1,3,1,9,7,3` sobre agência + `"00"` + conta, desprezando as dezenas | | Banrisul | `041` | 4 dígitos | 9 dígitos | pesos `3,2,4,7,6,5,4,3,2`; resto 0 gera `0` e resto 1 gera `6`; `account` é tipo (2 dígitos) + conta (7 dígitos) | | Caixa Econômica Federal | `104` | 4 dígitos | 11 dígitos | mod11 sobre agência + conta; `account` é operação (3 dígitos) + conta (8 dígitos) | -| Bradesco | `237` | 4 dígitos | 7 dígitos | mod11 com pesos 2..7; `digit` pode ser `"P"` (geralmente exibido como `"0"`) | +| Bradesco | `237` | 4 dígitos | 7 dígitos | mod11 com pesos 2..7 ciclando da direita para a esquerda; resto 0 gera `0` e resto 1 gera `"P"` | | Nubank | `260` | 4 dígitos | 5-13 dígitos | dígito de Verhoeff sobre a conta, ignorando zeros à esquerda | | Itaú Unibanco | `341` | 4 dígitos | 5 dígitos | mod10 sobre agência + conta | | HSBC / Kirton Bank | `399` | 4 dígitos | 6 dígitos | pesos `8,9,2,3,4,5,6,7,8,9` sobre agência + conta; resto 10 gera `0` | @@ -597,17 +656,15 @@ Bancos validados apenas pela estrutura, por não publicarem regra de dígito ver | Banco | Código | | Banco | Código | | --- | --- | --- | --- | --- | -| Inter | `077` | | PicPay | `380` | -| Ailos | `085` | | Cora | `403` | -| XP | `102` | | Pan | `623` | -| Unicred | `136` | | BV | `655` | -| Stone | `197` | | Daycoval | `707` | -| BTG Pactual | `208` | | Modal | `746` | -| Original | `212` | | Sicredi | `748` | -| PagBank | `290` | | Sicoob | `756` | -| BMG | `318` | | | | -| Mercado Pago | `323` | | | | -| C6 | `336` | | | | +| Inter | `077` | | Mercado Pago | `323` | +| Ailos | `085` | | C6 | `336` | +| XP | `102` | | PicPay | `380` | +| Unicred | `136` | | Cora | `403` | +| Stone | `197` | | Pan | `623` | +| BTG Pactual | `208` | | BV | `655` | +| Original | `212` | | Daycoval | `707` | +| PagBank | `290` | | Sicredi | `748` | +| BMG | `318` | | Sicoob | `756` | Quando `digit` tem 2 caracteres, o fallback genérico encadeia mod10 seguido de mod11 sobre a conta, do mesmo jeito que os dígitos de CPF/CNPJ são encadeados. @@ -710,7 +767,7 @@ getBankByCode('999'); // null ## getBankByIspb -Busca um banco brasileiro pelo seu ISPB (Identificador do Sistema de Pagamentos Brasileiro), o código de 8 dígitos publicado pelo Banco Central do Brasil na [lista de participantes do STR](https://www.bcb.gov.br/content/estabilidadefinanceira/str1/ParticipantesSTR.csv). Todo participante do SPB tem um ISPB, mas este conjunto de dados só traz as instituições que também têm código COMPE, então um ISPB cuja instituição não tem código COMPE próprio retorna `null`. Aceita tanto `string` quanto `number`, com ou sem zeros à esquerda. Retorna uma nova cópia (tipada como `Bank`) do banco correspondente, ou `null` quando nenhum banco tem esse ISPB. +Busca um banco brasileiro pelo seu ISPB (Identificador do Sistema de Pagamentos Brasileiro), o código de 8 dígitos publicado pelo Banco Central do Brasil na [lista de participantes do STR](https://www.bcb.gov.br/content/estabilidadefinanceira/str1/ParticipantesSTR.csv). Todo participante do SPB tem um ISPB, mas este conjunto de dados só traz as instituições que também têm código COMPE, então um ISPB cuja instituição não tem código COMPE próprio retorna `null`. Aceita tanto `string` quanto `number`, com ou sem zeros à esquerda, então `getBankByIspb(0)` encontra o mesmo banco que `getBankByIspb('00000000')`. O conjunto de dados é gerado a partir desse CSV, recorrendo à [BrasilAPI](https://brasilapi.com.br/api/banks/v1) quando a requisição ao Bacen falha. Retorna uma nova cópia (tipada como `Bank`) do banco correspondente, ou `null` quando nenhum banco tem esse ISPB. ```javascript import { getBankByIspb } from '@brazilian-utils/brazilian-utils'; @@ -722,21 +779,22 @@ getBankByIspb('99999999'); // null ## isValidIban -Valida se um IBAN (International Bank Account Number) brasileiro é válido, conforme as [Diretrizes de Implementação do IBAN no Brasil](https://www.bcb.gov.br/content/estabilidadefinanceira/Documents/sistema_pagamentos_brasileiro/IBAN-Guidelines_%20port.pdf) do Bacen (Circular BCB nº 3.625/2013): `BR` + 2 dígitos verificadores ISO 7064 MOD 97-10 + 8 dígitos de ISPB + 5 dígitos de agência + 10 dígitos de conta + 1 letra de tipo de conta (qualquer letra, normalmente `C` para conta corrente ou `P` para conta poupança) + 1 caractere alfanumérico de titularidade, totalizando 29 caracteres. Somente IBANs brasileiros (código de país `BR`) são reconhecidos; qualquer outro país retorna `false`, já que este pacote não conhece o layout de campos dos outros mais de 90 países da ISO 13616. Aceita os espaços de agrupamento usuais e não diferencia maiúsculas de minúsculas. O valor precisa estar escrito no formato impresso da ISO 13616: letras e dígitos em grupos separados por um único espaço, com espaços em branco opcionais no início e no fim. Qualquer outro caractere faz do valor algo que não é um IBAN, então ele é rejeitado em vez de removido. +Valida se um IBAN (International Bank Account Number) brasileiro é válido, conforme as [Diretrizes de Implementação do IBAN no Brasil](https://www.bcb.gov.br/content/estabilidadefinanceira/Documents/sistema_pagamentos_brasileiro/IBAN-Guidelines_%20port.pdf) do Bacen (Circular BCB nº 3.625/2013): `BR` + 2 dígitos verificadores ISO 7064 MOD 97-10 + 8 dígitos de ISPB + 5 dígitos de agência + 10 dígitos de conta + 1 letra de tipo de conta (qualquer letra, normalmente `C` para conta corrente ou `P` para conta poupança) + 1 indicador de titularidade (`1` para o primeiro ou único titular até `9` para o nono, depois `A` a `Z` a partir do décimo, então `0` é rejeitado), totalizando 29 caracteres. Somente IBANs brasileiros (código de país `BR`) são reconhecidos; qualquer outro país retorna `false`, já que este pacote não conhece o layout de campos dos outros mais de 90 países da ISO 13616. Não diferencia maiúsculas de minúsculas e aceita as duas formas em que um IBAN é escrito: compacta (`'BR1500000000000010932840814P2'`) ou no formato impresso da ISO 13616, letras e dígitos em grupos de 4 (o último menor), em ambos os casos com espaços em branco opcionais no início e no fim. Os grupos podem ser separados por espaço em branco, `.`, `-` ou `/`, os caracteres de máscara intercambiáveis que `isValidCpf` e `isValidCnpj` aceitam. Apenas um separador fora do limite de um grupo, uma sequência de separadores (a ISO 13616 imprime um único) ou um caractere fora de letras e dígitos faz do valor algo que não é um IBAN, então ele é rejeitado em vez de removido. ```javascript import { isValidIban } from '@brazilian-utils/brazilian-utils'; isValidIban('BR1500000000000010932840814P2'); // true isValidIban('BR15 0000 0000 0000 1093 2840 814P 2'); // true (espaços de agrupamento) +isValidIban('BR15-0000-0000-0000-1093-2840-814P-2'); // true (qualquer um dos caracteres de máscara) isValidIban('BR1500000000000010932840814P3'); // false (dígitos verificadores inválidos) -isValidIban('BR1500000000000010932840814P-2'); // false (hífen não faz parte de um IBAN) +isValidIban('BR15 000 00000 0000 1093 2840 814P 2'); // false (separador dentro de um grupo) isValidIban('DE89370400440532013000'); // false (IBAN não brasileiro) ``` ## formatIban -Formata um IBAN brasileiro agrupando-o em blocos de 4 caracteres, a apresentação "impressa" da ISO 13616 usada em extratos e formulários bancários. Não valida os dígitos verificadores nem o layout dos campos; formata o que for passado, até o limite de 29 caracteres de um IBAN brasileiro, até onde for possível, então a função também pode ser usada como máscara de digitação. Use `isValidIban` para verificar a validade. O valor ainda precisa estar escrito no formato impresso da ISO 13616 (letras e dígitos em grupos separados por um único espaço, com espaços em branco opcionais no início e no fim); qualquer outro caractere resulta em uma string vazia, em vez de ser descartado silenciosamente. +Formata um IBAN no agrupamento impresso da ISO 13616, blocos de 4 caracteres, a apresentação usada em extratos e formulários bancários. Não valida os dígitos verificadores nem o layout dos campos; formata o que for passado, até o limite de 29 caracteres de um IBAN brasileiro, até onde for possível, então a função também pode ser usada como máscara de digitação, e um IBAN de outro país é agrupado do mesmo jeito até esse limite. Use `isValidIban` para verificar a validade. O valor pode ser compacto (`'BR1500000000000010932840814P2'`), já estar no formato impresso da ISO 13616 ou ser um valor parcial ainda sendo digitado (`'BR15'`); como todo formatador deste pacote, ele é lido pelas suas letras e dígitos e agrupado até onde eles vão, qualquer outro caractere (hífen, ponto, espaço a mais) é descartado e as letras viram maiúsculas. Só um valor que não seja string resulta em uma string vazia. ```javascript import { formatIban } from '@brazilian-utils/brazilian-utils'; @@ -744,17 +802,28 @@ import { formatIban } from '@brazilian-utils/brazilian-utils'; formatIban('BR1500000000000010932840814P2'); // 'BR15 0000 0000 0000 1093 2840 814P 2' formatIban('br1500000000000010932840814p2'); // 'BR15 0000 0000 0000 1093 2840 814P 2' formatIban('BR15'); // 'BR15' -formatIban('BR1500000000000010932840814P-2'); // '' (hífen não faz parte de um IBAN) +formatIban('BR15 0000-0000.0000/1093 2840 814P-2'); // 'BR15 0000 0000 0000 1093 2840 814P 2' (só letras e dígitos são lidos) ``` ## parseIban -Interpreta um IBAN brasileiro em seus campos: 2 (código do país, sempre `BR`) + 2 (dígitos verificadores ISO 7064 MOD 97-10) + 8 (ISPB) + 5 (agência) + 10 (conta) + 1 (tipo de conta, qualquer letra, normalmente `C` para conta corrente ou `P` para conta poupança) + 1 (indicador do titular). Aceita as mesmas formas de entrada que `isValidIban` (espaços de agrupamento, minúsculas) e retorna `null` sempre que `isValidIban` retornaria `false`, inclusive quando o valor carrega qualquer caractere além de letras, dígitos e os espaços de agrupamento do formato impresso. O resultado é tipado como `Iban`, cujo `accountType` é uma `string`. +Remove a formatação do IBAN, mantém as letras e os dígitos, coloca o resultado em maiúsculas e o limita aos 29 caracteres de um IBAN brasileiro. Um IBAN carrega letras além de dígitos, então o valor é lido como o `parsePassport` lê um número de passaporte; use `isValidIban` para verificar os dígitos verificadores e `getIbanInfo` para ler os campos. ```javascript import { parseIban } from '@brazilian-utils/brazilian-utils'; -parseIban('BR1500000000000010932840814P2'); +parseIban('BR15 0000 0000 0000 1093 2840 814P 2'); // 'BR1500000000000010932840814P2' +parseIban('br15-0000.0000/0000 1093 2840 814p-2'); // 'BR1500000000000010932840814P2' +``` + +## getIbanInfo + +Interpreta um IBAN brasileiro em seus campos: 2 (código do país, sempre `BR`) + 2 (dígitos verificadores ISO 7064 MOD 97-10) + 8 (ISPB) + 5 (agência) + 10 (conta) + 1 (tipo de conta, qualquer letra, normalmente `C` para conta corrente ou `P` para conta poupança) + 1 (indicador do titular, `1` a `9` e depois `A` a `Z`). Apenas IBANs brasileiros são suportados: o layout de campos dos demais países da ISO 13616 está fora de escopo, então um IBAN bem formado que não seja `BR` também retorna `null`. Aceita as mesmas formas de entrada que `isValidIban`, compacta ou no formato impresso da ISO 13616 (grupos de 4 separados por um único espaço em branco, `.`, `-` ou `/`), em ambos os casos com espaços em branco opcionais no início e no fim e sem diferenciar maiúsculas de minúsculas, e retorna `null` sempre que `isValidIban` retornaria `false`, inclusive quando o valor carrega um separador fora do limite de um grupo, uma sequência de separadores ou qualquer caractere além de letras e dígitos. O resultado é tipado como `IbanInfo`, cujo `accountType` é uma `string`. + +```javascript +import { getIbanInfo } from '@brazilian-utils/brazilian-utils'; + +getIbanInfo('BR1500000000000010932840814P2'); // { // countryCode: 'BR', // checkDigits: '15', @@ -765,13 +834,13 @@ parseIban('BR1500000000000010932840814P2'); // owner: '2' // } -parseIban('DE89370400440532013000'); // null (IBAN não brasileiro) -parseIban('BR1500000000000010932840814P-2'); // null (hífen não faz parte de um IBAN) +getIbanInfo('DE89370400440532013000'); // null (IBAN não brasileiro) +getIbanInfo('BR15 000 00000 0000 1093 2840 814P 2'); // null (separador dentro de um grupo) ``` ## isValidCreditCard -Valida se um número de cartão de pagamento é válido usando o algoritmo de Luhn ([ISO/IEC 7812-1](https://www.iso.org/standard/70484.html)). Aceita os caracteres de máscara usuais (espaços, hifens) entre os dígitos. Não faz detecção de bandeira (Visa, Mastercard, Amex...), consulta de faixa de emissor nem validação de validade/CVV, verifica apenas a quantidade de dígitos (12 a 19) e o dígito verificador de Luhn. Um `number` só é aceito quando é um inteiro seguro não negativo: qualquer valor acima de `Number.MAX_SAFE_INTEGER` (2^53 - 1, 16 dígitos) já chega arredondado para outro número, então passe cartões mais longos como string. +Valida se um número de cartão de pagamento é válido usando o algoritmo de Luhn ([ISO/IEC 7812-1](https://www.iso.org/standard/70484.html)). Aceita os caracteres de máscara usuais (espaço em branco, `.`, `-` e `/`, o conjunto intercambiável que `isValidCpf` e `isValidCnpj` aceitam) entre dois dígitos quaisquer e espaços ao redor do valor; qualquer outro caractere invalida o valor. Eles são aceitos entre dois dígitos quaisquer, e não em posições fixas, porque o agrupamento impresso de um PAN muda com a bandeira (4-4-4-4 para Visa e Mastercard, 4-6-5 para American Express, 4-6-4 para Diners Club), então não há um único leiaute ao qual prendê-los. Não faz detecção de bandeira (Visa, Mastercard, Amex...), consulta de faixa de emissor nem validação de validade/CVV, verifica apenas a quantidade de dígitos (12 a 19) e o dígito verificador de Luhn. Um `number` só é aceito quando é um inteiro seguro não negativo: qualquer valor acima de `Number.MAX_SAFE_INTEGER` (2^53 - 1, 16 dígitos) já chega arredondado para outro número, então passe cartões mais longos como string. Um valor cujos dígitos são todos iguais (`'0000000000000000'`) é rejeitado mesmo passando no cálculo de Luhn, do jeito que todo outro validador deste pacote rejeita um documento de dígitos repetidos (`isValidCpf('00000000000')`, `isValidCns`, `isValidCaepf`, `isValidCei`). ```javascript import { isValidCreditCard } from '@brazilian-utils/brazilian-utils'; @@ -780,30 +849,52 @@ isValidCreditCard('4111111111111111'); // true (número de teste Visa) isValidCreditCard('5555555555554444'); // true (número de teste Mastercard) isValidCreditCard('378282246310005'); // true (número de teste American Express) isValidCreditCard('4111 1111 1111 1111'); // true (máscara com espaços) +isValidCreditCard('4111.1111/1111-1111'); // true (qualquer um dos caracteres de máscara) isValidCreditCard('4111111111111112'); // false (dígito verificador inválido) +isValidCreditCard('0000000000000000'); // false (todos os dígitos iguais, ainda que o Luhn feche) +isValidCreditCard('4111a1111b1111c1111'); // false (letras entre os dígitos) isValidCreditCard(4111111111111111111); // false (acima de 2^53 - 1, passe como string) ``` ## capitalize -Transforma primeira letra de cada palavra em maiúscula ignorando preposições. As palavras são separadas por espaço em branco, por `-` e por `/`, então `'MOGI-GUAÇU'` vira `'Mogi-Guaçu'` e `'SANTANA/RS'` vira `'Santana/Rs'`. Toda sequência de espaços em branco (tabs, quebras de linha, espaços repetidos) vira um único espaço, e o espaço no início e no fim é descartado. `options.upperCaseWords` tem como padrão `[]`, ou seja, nenhuma sigla é colocada em maiúsculas a menos que você a liste, e a comparação com `upperCaseWords` e `lowerCaseWords` é case-insensitive (locale pt-BR). As opções são tipadas como `CapitalizeOptions`. +Transforma a primeira letra de cada palavra em maiúscula do jeito que se escreve um nome, uma razão social ou um endereço brasileiro, sem precisar de opções. As palavras são separadas por espaço em branco, por `-` e `/`, pelo apóstrofo (`'d'oeste'` vira `'d'Oeste'`) e pela pontuação colada à palavra (`'(empresa)'` vira `'(Empresa)'`, `'bairro:centro'` vira `'Bairro:Centro'`), então `'MOGI-GUAÇU'` vira `'Mogi-Guaçu'`; os separadores ficam onde estão. Toda sequência de espaços em branco (tabs, quebras de linha, espaços repetidos) vira um único espaço, e o espaço no início e no fim é descartado. As partículas de nomes de origem estrangeira (`del`, `della`, `di`, `du`, `van`, `von`, `der`, `den`) ficam em minúsculas como as preposições do português, e a partícula elidida `d'` também, onde quer que apareça, sempre que um apóstrofo e uma palavra vierem logo depois (`'dias d'ávila'` vira `'Dias d'Ávila'`); uma letra sozinha logo depois de um apóstrofo é o possessivo do inglês e também fica em minúscula (`"bob's"` vira `"Bob's"`). + +`options.lowerCaseWords` tem como padrão as preposições, artigos e conjunções que permanecem em minúsculas dentro de um nome próprio (`de`, `da`, `do`, `e`, ...), e elas só ficam em minúsculas quando ligam duas palavras: uma delas que seja a primeira palavra, que encerre o valor ou que venha antes de uma pontuação é um designativo e mantém a maiúscula (`'rua a, 100'` vira `'Rua A, 100'` e `'condomínio a, quadra d, lote o'` vira `'Condomínio A, Quadra D, Lote O'`). `options.upperCaseWords` tem como padrão as designações societárias e as abreviações de documentos escritas em maiúsculas no uso brasileiro (`LTDA`, `S.A.`, `S/A`, `S.S.`, `S/S`, `ME`, `EPP`, `MEI`, `EIRELI`, `CIA`, `SCP`, `CNPJ`, `CPF`, `RG`, `CEP`, `UF`) mais os algarismos romanos que aparecem em nomes e endereços (de `II` a `XXIII`, exceto `VI`, que colide com a forma verbal "vi"). `SA` sem pontuação ficou de fora de propósito, por ser indistinguível do sobrenome "Sá" digitado sem o acento, enquanto `ME` é também o pronome "me", então só fica em maiúsculas na posição de designação, como última palavra do valor (`'fulano comércio me'` vira `'Fulano Comércio ME'`) ou logo antes de outra designação (`'fulano me epp'` vira `'Fulano ME EPP'`); em qualquer outro lugar é uma palavra comum (`'diga-me a verdade'` vira `'Diga-Me a Verdade'`, `'não-me-toque'` vira `'Não-Me-Toque'`). `S/A` e `S/S` são reconhecidos mesmo com a barra no meio, embora a barra separe palavras. Uma palavra de duas letras logo depois de uma `/` vira maiúscula quando é a sigla de um estado brasileiro (`'porto alegre/rs'` vira `'Porto Alegre/RS'`); essa regra é estrutural e continua valendo mesmo com `upperCaseWords` informado, enquanto uma sigla de estado que não venha depois de uma `/` é deixada como está. + +Qualquer uma das listas informada em `options` substitui inteiramente a lista padrão correspondente, e a comparação com as duas é case-insensitive (locale pt-BR). As opções são tipadas como `CapitalizeOptions`. ```javascript import { capitalize } from '@brazilian-utils/brazilian-utils'; -capitalize('josé e maria'); // José e Maria +capitalize('jose da silva'); // Jose da Silva +capitalize('JOSÉ DA SILVA'); // José da Silva +capitalize('empresa ltda'); // Empresa LTDA +capitalize('banco do brasil s.a.'); // Banco do Brasil S.A. +capitalize('casa de carnes s/a'); // Casa de Carnes S/A ("S/A" é reconhecido com a barra no meio) +capitalize('mogi-guaçu'); // Mogi-Guaçu ("-" inicia uma nova palavra) +capitalize("santa bárbara d'oeste"); // Santa Bárbara d'Oeste ("'" inicia uma nova palavra, "d" fica minúsculo) +capitalize("bob's"); // Bob's (uma letra sozinha depois do apóstrofo é o possessivo do inglês) +capitalize('rua a, 100'); // Rua A, 100 (uma preposição antes de pontuação é um designativo) +capitalize('fulano comércio me'); // Fulano Comércio ME ("ME" como última palavra é a designação) +capitalize('não-me-toque'); // Não-Me-Toque (em qualquer outro lugar "me" é palavra comum) +capitalize('(empresa) ltda'); // (Empresa) LTDA +capitalize('luiz von schmidt'); // Luiz von Schmidt +capitalize('santana/rs'); // Santana/RS ("RS" é sigla de estado logo depois de uma "/") +capitalize('porto alegre/rs'); // Porto Alegre/RS +capitalize('santana rs'); // Santana Rs (sem "/", "rs" é só uma palavra) +capitalize('rua xv de novembro'); // Rua XV de Novembro (algarismo romano, "de" fica em minúsculas) +capitalize('joão paulo ii'); // João Paulo II +capitalize('de'); // De (uma preposição mantém a maiúscula quando é a primeira palavra) +capitalize('empresa ltda', { upperCaseWords: [] }); // Empresa Ltda (a lista informada substitui a padrão) capitalize('josé Ama MARIA', { lowerCaseWords: ['ama'] }); // José ama Maria -capitalize('doc inválido', { upperCaseWords: ['DOC'] }); // DOC Inválido -capitalize('MOGI-GUAÇU'); // Mogi-Guaçu ("-" inicia uma nova palavra) -capitalize('SANTANA/RS', { upperCaseWords: ['RS'] }); // Santana/RS ("/" inicia uma nova palavra, então "RS" corresponde) -capitalize('empresa ltda'); // Empresa Ltda (sem siglas padrão) -capitalize('empresa ltda', { upperCaseWords: ['LTDA'] }); // Empresa LTDA (comparação case-insensitive) +capitalize('doc inválido', { upperCaseWords: ['DOC'] }); // DOC Inválido (comparação case-insensitive) capitalize(' josé maria '); // José Maria (toda sequência de espaço em branco, tabs e quebras de linha inclusive, vira um único espaço) ``` ## formatCurrency -Formata um número inteiro ou float para uma string no padrão BRL. Um `number` é formatado como está (sinal e decimais preservados). Uma entrada em `string` é lida pela mesma regra do `parseCurrency`, com a diferença de que um valor escrito sem nenhum separador permanece em unidades inteiras: o último `,` ou `.` seguido de 1 ou 2 dígitos é o separador decimal, todo outro `,` ou `.` é separador de milhar, e um `-` escrito antes do primeiro dígito é preservado. Assim `'1.234,56'` vira `1.234,56`, `'-10.5'` vira `-10,50` e `'1234'` vira `1.234,00`. `precision` é limitado ao intervalo `0..20` (o aceito pelo `Intl.NumberFormat`) e o padrão é 2. Um valor que não seja um número finito (`NaN`, `Infinity`, `-Infinity`) vira string vazia. As opções são tipadas como `FormatCurrencyOptions`. +Formata um número inteiro ou float para uma string no padrão BRL. Um `number` é formatado como está (sinal e decimais preservados). Uma entrada em `string` é lida pela mesma regra do `parseCurrency`, com a diferença de que um valor escrito sem nenhum separador permanece em unidades inteiras: o último `,` ou `.` seguido de 1 ou 2 dígitos (ou de até `precision` dígitos, quando esse valor for maior) é o separador decimal, todo outro `,` ou `.` é separador de milhar, e um `-` escrito antes do primeiro dígito é preservado. Assim `'1.234,56'` vira `1.234,56`, `'-10.5'` vira `-10,50` e `'1234'` vira `1.234,00`. `precision` é limitado ao intervalo `0..20` (o limite do pacote, o que o Node 20 ainda impõe ao `Intl.NumberFormat`), o padrão é 2 e volta a 2 quando não é um número finito. Um valor que não seja um número finito (`NaN`, `Infinity`, `-Infinity`) vira string vazia, e um valor que não pode ser convertido em número (um symbol, um objeto simples, um objeto sem protótipo) também; `null`, arrays e booleanos passam por `Number()` como no 2.3.0. `options.symbol` prefixa o resultado com o símbolo monetário `R$` (padrão `false`). As opções são tipadas como `FormatCurrencyOptions`. ```javascript import { formatCurrency } from '@brazilian-utils/brazilian-utils'; @@ -821,7 +912,7 @@ formatCurrency(Number.NaN); // "" (números não finitos viram string vazia) ## parseCurrency -Transforma uma string para o formato de inteiro ou float. O último `,` ou `.` seguido de 1 ou 2 dígitos (ou de até `precision` dígitos, quando esse valor for maior) é o separador decimal; todo outro `,` ou `.` é separador de milhar. Assim `'R$ 1.234,56'` vira `1234.56`, `'R$ 1.234'` vira `1234`, `'1,5'` vira `1.5` e `'12.34'` vira `12.34`. Um valor escrito sem nenhum separador mantém a convenção de centavos e é dividido por `10 ** precision`, então `'1234'` vira `12.34`. Um `-` escrito antes do primeiro dígito é preservado, então `'-R$ 1,00'` vira `-1`. `precision` (padrão 2, limitado a `0..20`) controla quantos dígitos são tratados como centavos. As opções são tipadas como `ParseCurrencyOptions`. +Transforma uma string para o formato de inteiro ou float. O último `,` ou `.` seguido de 1 ou 2 dígitos (ou de até `precision` dígitos, quando esse valor for maior) é o separador decimal; todo outro `,` ou `.` é separador de milhar. Assim `'R$ 1.234,56'` vira `1234.56`, `'R$ 1.234'` vira `1234`, `'1,5'` vira `1.5` e `'12.34'` vira `12.34`. Um valor escrito sem nenhum separador mantém a convenção de centavos e é dividido por `10 ** precision`, então `'1234'` vira `12.34`. Um `-` escrito antes do primeiro dígito é preservado, então `'-R$ 1,00'` vira `-1`. `precision` (padrão 2, limitado a `0..20`, e voltando a 2 quando não é um número finito) controla quantos dígitos são tratados como subunidades monetárias. As opções são tipadas como `ParseCurrencyOptions`. ```javascript import { parseCurrency } from '@brazilian-utils/brazilian-utils'; @@ -839,7 +930,7 @@ parseCurrency(''); // 0 ## convertNumberToWords -Formata um número inteiro por extenso em português do Brasil, ex.: `1235` vira `"mil, duzentos e trinta e cinco"`. Só são suportados inteiros de `-999999999999999` a `999999999999999` (999 trilhões em valor absoluto); fora desse intervalo, `NaN` ou um valor não finito retornam `""`. Um `value` não inteiro é truncado em direção a zero antes da conversão. `options.gender` (parte de `ConvertNumberToWordsOptions`) concorda "um/dois" e a centena ("duzentos/duzentas" etc.) com o substantivo que o número qualifica, com padrão `"masculine"`. `options.case` define a caixa do resultado: `"lower"` (padrão, sem alteração), `"sentence"` (só a primeira letra em maiúscula) ou `"upper"` (tudo em maiúscula pelo locale "pt-BR", preservando os acentos, ex.: "três" -> "TRÊS"). Um valor inválido de `gender`/`case` é ignorado e o padrão é usado. +Formata um número inteiro por extenso em português do Brasil, ex.: `1235` vira `"mil duzentos e trinta e cinco"`. Só são suportados inteiros de `-999999999999999` a `999999999999999` (999 trilhões em valor absoluto); fora desse intervalo, `NaN` ou um valor não finito retornam `""`. Um `value` não inteiro é truncado em direção a zero antes da conversão. `options.gender` (parte de `ConvertNumberToWordsOptions`) concorda "um/dois" e a centena ("duzentos/duzentas" etc.) com o substantivo que o número qualifica, com padrão `"masculine"`. Um valor inválido de `gender` é ignorado e o padrão é usado. O resultado sai sempre em minúsculas; aplique qualquer outra caixa por conta própria. ```javascript import { convertNumberToWords } from '@brazilian-utils/brazilian-utils'; @@ -849,25 +940,24 @@ convertNumberToWords(1001); // "mil e um" convertNumberToWords(2000000); // "dois milhões" convertNumberToWords(-42); // "menos quarenta e dois" convertNumberToWords(2, { gender: 'feminine' }); // "duas" -convertNumberToWords(3, { case: 'upper' }); // "TRÊS" +convertNumberToWords(12.9); // "doze" (truncado em direção a zero) convertNumberToWords(NaN); // "" ``` ## convertCurrencyToWords -Formata um valor monetário em Reais por extenso, no estilo usado para escrever o valor à mão em cheques e contratos, ex.: `1523.45` vira `"mil, quinhentos e vinte e três reais e quarenta e cinco centavos"`. O `value` é truncado (não arredondado) para 2 casas decimais. O substantivo no singular é usado para exatamente 1 ("um real", "um centavo") e "de" é inserido antes de "reais" quando o valor é um milhão, bilhão ou trilhão de reais redondo. Um valor que trunca para nada vira `"zero reais"`, sem o prefixo "menos"; qualquer outro valor negativo recebe o prefixo "menos", e uma entrada inválida retorna `""`. Acima de `Number.MAX_SAFE_INTEGER / 100` reais (cerca de 90 trilhões) um double não consegue carregar centavos, então o valor é lido como um número inteiro de reais. `options.case` (parte de `ConvertCurrencyToWordsOptions`) define a caixa do resultado: `"lower"` (padrão), `"sentence"` (só a primeira letra em maiúscula) ou `"upper"` (tudo em maiúscula, preservando os acentos). Um valor inválido de `case` é ignorado e `"lower"` é usado. +Formata um valor monetário em Reais por extenso, no estilo usado para escrever o valor à mão em cheques e contratos, ex.: `1523.45` vira `"mil quinhentos e vinte e três reais e quarenta e cinco centavos"`. O `value` é truncado (não arredondado) para 2 casas decimais. O substantivo no singular é usado para exatamente 1 ("um real", "um centavo") e "de" é inserido antes de "reais" quando o valor é um milhão, bilhão ou trilhão de reais redondo. Um valor que trunca para nada vira `"zero reais"`, sem o prefixo "menos"; qualquer outro valor negativo recebe o prefixo "menos", e uma entrada inválida retorna `""`. Acima de `Number.MAX_SAFE_INTEGER / 100` reais (cerca de 90 trilhões) um double não consegue carregar centavos, então o valor é lido como um número inteiro de reais. Não recebe opções: o resultado sai sempre em minúsculas; aplique qualquer outra caixa por conta própria. ```javascript import { convertCurrencyToWords } from '@brazilian-utils/brazilian-utils'; -convertCurrencyToWords(1523.45); // "mil, quinhentos e vinte e três reais e quarenta e cinco centavos" +convertCurrencyToWords(1523.45); // "mil quinhentos e vinte e três reais e quarenta e cinco centavos" convertCurrencyToWords(1); // "um real" convertCurrencyToWords(0.01); // "um centavo" convertCurrencyToWords(1000000); // "um milhão de reais" convertCurrencyToWords(0); // "zero reais" convertCurrencyToWords(-5.5); // "menos cinco reais e cinquenta centavos" convertCurrencyToWords(-0.001); // "zero reais" (trunca para nada) -convertCurrencyToWords(1000, { case: 'upper' }); // "MIL REAIS" ``` ## getStates @@ -911,7 +1001,7 @@ getStates(); ## getStateByIbgeCode -Retorna o estado brasileiro cujo código IBGE de 2 dígitos ("cUF", Código da Unidade da Federação) corresponde ao valor informado. É o mesmo código de UF de 2 dígitos presente no primeiro campo de toda chave de acesso de DF-e (NF-e, NFC-e, CT-e e MDF-e). Aceita string ou número inteiro não negativo, removendo caracteres não numéricos antes de comparar. Exporta o tipo `State`. +Retorna o estado brasileiro cujo código IBGE de 2 dígitos ("cUF", Código da Unidade da Federação) corresponde ao valor informado. É o mesmo código de UF de 2 dígitos presente no primeiro campo de toda chave de acesso de DF-e de qualquer um dos modelos que o `isValidNfeKey` cobre: NF-e (55), NFC-e (65), CT-e (57), MDF-e (58), CT-e OS (67), GTV-e (64), BP-e (63), NF3e (66) e NFCom (62). Aceita string ou número inteiro não negativo, removendo caracteres não numéricos antes de comparar. Exporta o tipo `State`. ```javascript import { getStateByIbgeCode } from '@brazilian-utils/brazilian-utils'; @@ -929,7 +1019,7 @@ getStateByIbgeCode(3.5); // null ## getStateCodeByName -Retorna a sigla de um estado brasileiro a partir do nome completo. A comparação ignora acentos, maiúsculas/minúsculas e espaços nas pontas, então `'sao paulo'`, `'SÃO PAULO'` e `' São Paulo '` resolvem para `'SP'`. Exporta o tipo `StateCode`. +Retorna a sigla de um estado brasileiro a partir do nome completo. A comparação ignora acentos, maiúsculas/minúsculas e espaços nas pontas, então `'sao paulo'`, `'SÃO PAULO'` e `' São Paulo '` resolvem para `'SP'`. Toda sequência de espaços internos também vira um único espaço, então `'Rio de Janeiro'` resolve para `'RJ'`, enquanto um nome escrito sem o espaço não corresponde a nada (`'saopaulo'` não é `'São Paulo'`). Exporta o tipo `StateCode`. ```javascript import { getStateCodeByName } from '@brazilian-utils/brazilian-utils'; @@ -969,7 +1059,7 @@ getTimezoneByState('ZZ'); // null ## getCities -Retorna as cidades brasileiras. Retorna todas as cidades se nenhum estado for fornecido, ou cidades de um estado específico. Cada chamada retorna um array novo, então alterar o resultado nunca afeta chamadas seguintes. Um código de estado desconhecido (ou um valor que não seja `StateCode`) retorna um array vazio em vez de lançar erro. +Retorna as cidades brasileiras. **Obsoleta:** use `getMunicipalities` no lugar. Retorna todas as cidades se nenhum estado for fornecido, ou cidades de um estado específico. Cada chamada retorna um array novo, então alterar o resultado nunca afeta chamadas seguintes. Um código de estado desconhecido (ou um valor que não seja `StateCode`) retorna um array vazio em vez de lançar erro, exceto quando é um valor falsy: `getCities(null)` e `getCities('')` são lidos como "nenhum estado informado" e retornam todas as cidades, enquanto o mais estrito `getMunicipalities` retorna `[]` para eles. O código do estado é comparado exatamente, inclusive na caixa: `getCities('sp')` retorna `[]` enquanto `getCities('SP')` retorna as 645 cidades paulistas. `getCities` e `getMunicipalities` são as únicas buscas por estado sensíveis à caixa; `getStateNameByCode`, `getTimezoneByState`, `getAreaCodesByState` e `getMunicipality` ignoram a caixa. ```javascript import { getCities } from '@brazilian-utils/brazilian-utils'; @@ -987,7 +1077,7 @@ getCities(); // 'Abaré', // 'Abatiá', // 'Abdon Batista', -// ... 5561 more items +// ... mais 5561 itens // ] // Retorna todas as cidades brasileiras do estado de São Paulo (ordenadas alfabeticamente). @@ -1003,15 +1093,26 @@ getCities('SP'); // "Agudos", // "Alambari", // "Alfredo Marcondes", -// ... 635 more items +// ... mais 635 itens // ] ``` -`getCities` embute os nomes dos 5571 municípios do IBGE (~153 KB minificado, ~49 KB com gzip) e é uma das poucas exceções pesadas neste pacote, que é tree-shakeable no restante. Veja [Tamanho do bundle](getting-started.md#tamanho-do-bundle) para saber como carregá-lo sob demanda via `@brazilian-utils/brazilian-utils/get-cities` em vez do import da raiz. +`getCities` embute os nomes dos 5571 municípios do IBGE (~154,2 KB minificado, ~49,8 KB com gzip) e é uma das poucas exceções pesadas neste pacote, que é tree-shakeable no restante. Veja [Tamanho do bundle](getting-started.md#tamanho-do-bundle) para saber como carregá-lo sob demanda via `@brazilian-utils/brazilian-utils/get-cities` em vez do import da raiz. ## getHolidays -Retorna feriados brasileiros para um determinado ano. Retorna feriados nacionais e opcionalmente feriados estaduais. Cada feriado (tipado como `Holiday`) tem um campo `type` (`HolidayType`: `"national"`, `"state"`, `"optional"` ou `"religious"`). O "Dia da Consciência Negra" (20 de novembro) é feriado nacional a partir de 2024 (Lei nº 14.759/2023). Antes disso, MT e RJ ainda trazem seu próprio feriado estadual chamado `"Consciência Negra"` na mesma data. Os resultados são memoizados por `year`/`stateCode`, mas cada chamada ainda retorna uma cópia nova. Um `stateCode` desconhecido/inválido é ignorado, retornando apenas os feriados nacionais. +Retorna feriados brasileiros para um determinado ano. Retorna feriados nacionais e opcionalmente feriados estaduais. Cada feriado (tipado como `Holiday`) tem um campo `type` (`HolidayType`: `"national"`, `"state"`, `"optional"` ou `"religious"`). O "Dia da Consciência Negra" (20 de novembro) é feriado nacional a partir de 2024 (Lei nº 14.759/2023). Antes disso, vários estados ainda trazem um feriado estadual próprio na mesma data: `"Consciência Negra"` em MT e RJ, `"Dia Estadual da Consciência Negra"` no AP e `"Dia da Consciência Negra"` no AM e em SP. Os resultados são memoizados por `year`/`stateCode`, mas cada chamada ainda retorna uma cópia nova. Um `stateCode` desconhecido/inválido é ignorado, retornando apenas os feriados nacionais; a busca lê apenas propriedades próprias, então `"__proto__"`, `"constructor"` e afins são códigos desconhecidos como qualquer outro, e não uma exceção. Só os anos de 1900 a 2099 são suportados, o intervalo que os utilitários de dias úteis herdam; um ano fora dele retorna `[]`. + +Apenas um feriado estadual por UF é feriado civil pela [Lei nº 9.093/1995](https://www.planalto.gov.br/ccivil_03/leis/l9093.htm), art. 1º, II, que autoriza "a data magna do Estado fixada em lei estadual", no singular; as demais entradas se apoiam em leis estaduais ordinárias e são reportadas por serem observadas na prática. Regras notáveis por estado: + +- **SC** — a [Lei SC nº 18.531/2022](http://leis.alesc.sc.gov.br/html/2022/18531_2022_lei.html) transfere os dois feriados estaduais, "Dia do Estado de Santa Catarina" (11/08) e "Dia de Santa Catarina de Alexandria" (25/11), para o domingo subsequente sempre que caem de segunda a sexta, então a segunda-feira 11/08/2025 é dia útil em SC e o feriado cai no domingo 17/08. As duas datas não passaram a ser transferidas juntas. O 11/08 é transferido a partir de 2005, ano em que a [Lei SC nº 13.408/2005](http://leis.alesc.sc.gov.br/html/2005/13408_2005_lei.html) estendeu a cláusula a ele (publicada e em vigor em 15/07/2005), e antes disso fica em 11/08. O 25/11 é transferido a partir de 1999, ano em que a [Lei SC nº 11.213/1999](http://leis.alesc.sc.gov.br/html/1999/11213_1999_lei.html) introduziu a cláusula (publicada e em vigor em 12/11/1999, treze dias antes do 25/11 daquele ano), com um intervalo de um ano: o art. 3º da [Lei SC nº 12.906/2004](http://leis.alesc.sc.gov.br/html/2004/12906_2004_lei.html) revogou aquela lei sem repetir a cláusula, então só o 25/11/2004 fica na data estatutária, até a Lei SC nº 13.408/2005 reinstituir a transferência. Assim, o 25/11/1999 (uma quinta-feira) cai no domingo 28/11, o 25/11/2002 (uma segunda-feira) no domingo 01/12, o 25/11/2004 (uma quinta-feira) não se move, e o 25/11/2005 (uma sexta-feira) cai no domingo 27/11. +- **DF** — a [Lei distrital nº 72/1989](https://www.sinj.df.gov.br/sinj/Norma/18459/Lei_72_27_12_1989.html), art. 1º parágrafo único, declara Corpus Christi feriado. Com `stateCode: 'DF'` a única entrada de Corpus Christi volta tipada como `"state"` em vez de `"optional"`; ela é substituída, não duplicada. +- **GO** — a [Lei GO nº 20.756/2020](https://legisla.casacivil.go.gov.br/pesquisa_legislacao/100979/lei-20756), art. 269, II, lista três feriados estaduais: 26/07 (Fundação da Cidade de Goiás), 24/10 (Lançamento da Pedra Fundamental de Goiânia) e 28/10 (Dia do Servidor Público). +- **AL** — 16/09 é feriado estadual a partir de 2024 ([Lei AL nº 9.358/2024](https://sapl.al.al.leg.br/norma/3117)) e apenas ponto facultativo (`"optional"`) antes disso. +- **PB** — 26/07 ("Morte de João Pessoa") é emitido apenas até 2015: a [Lei PB nº 10.601/2015](https://sapl.al.pb.leg.br/norma/11988), art. 2º, revogou a sua base. +- **TO** — 18/03 ("Autonomia do Estado do Tocantins") é emitido apenas até 2008: a [Lei TO nº 2.013/2009](https://www.al.to.leg.br/arquivo/15724) transformou em meramente comemorativo o dispositivo que declarava o feriado. + +A data retornada é a legal. O deslocamento de SC acima é o único modelado; o de Acre (feriados de terça a quinta transferidos para a sexta) e os decretos goianos que podem mover 26/07 e 28/10 não são. ```javascript import { getHolidays } from '@brazilian-utils/brazilian-utils'; @@ -1034,7 +1135,7 @@ getHolidays({ year: 2024, stateCode: 'SP' }); ## isValidPassport -Verifica se um número de passaporte brasileiro é válido (2 letras seguidas de 6 dígitos). Aceita tanto `string` quanto `number`; a entrada é case-insensitive e caracteres não alfanuméricos (espaços, pontos, hífens) são ignorados. +Verifica se um número de passaporte brasileiro é válido (2 letras seguidas de 6 dígitos). Aceita tanto `string` quanto `number`; a entrada é case-insensitive e caracteres não alfanuméricos (espaços, pontos, hífens) são ignorados. Um `number` é aceito por simetria com `formatPassport`/`parsePassport`, mas nunca é válido: a forma decimal de um número nunca começa com as duas letras que um número de passaporte exige. ```javascript import { isValidPassport } from '@brazilian-utils/brazilian-utils'; @@ -1058,7 +1159,7 @@ formatPassport('AB-123.456'); // 'AB123456' ## generatePassport -Gera um número de passaporte brasileiro válido aleatoriamente. +Gera um número de passaporte brasileiro válido aleatoriamente. Usa `Math.random()` internamente, então não é criptograficamente seguro. ```javascript import { generatePassport } from '@brazilian-utils/brazilian-utils'; @@ -1079,7 +1180,7 @@ parsePassport(' AB 123 456 '); // 'AB123456' ## generateCep -Gera um CEP aleatório. +Gera um CEP aleatório. Usa `Math.random()` internamente, então não é criptograficamente seguro. ```javascript import { generateCep } from '@brazilian-utils/brazilian-utils'; @@ -1089,7 +1190,7 @@ generateCep(); // '92500000' ## formatCnh -Formata a CNH. +Formata a CNH. `options.pad` (parte de `FormatCnhOptions`) completa o valor com zeros à esquerda até os 11 dígitos antes de aplicar a máscara (padrão `false`). ```javascript import { formatCnh } from '@brazilian-utils/brazilian-utils'; @@ -1100,17 +1201,19 @@ formatCnh('2650306461', { pad: true }); // 026503064-61 ## isValidCnh -Valida se a CNH é válida. +Valida se a CNH é válida. Espaços, pontos e hífens ao redor/entre os dígitos são ignorados, mas qualquer outro caractere, uma letra em especial, invalida o valor. Um valor cujos 11 dígitos são todos iguais é rejeitado antes do cálculo dos dígitos verificadores, então `'11111111111'` é inválido. ```javascript import { isValidCnh } from '@brazilian-utils/brazilian-utils'; isValidCnh('00000000119'); // true +isValidCnh('000000001-19'); // true (hífen antes dos dígitos verificadores) +isValidCnh('ab00000000119'); // false (letras são rejeitadas) ``` ## generateCnh -Gera uma CNH válida aleatória. +Gera uma CNH válida aleatória. Usa `Math.random()` internamente, então não é criptograficamente seguro. ```javascript import { generateCnh } from '@brazilian-utils/brazilian-utils'; @@ -1120,7 +1223,7 @@ generateCnh(); // '02650306461' ## parseCnh -Remove a formatação da CNH, mantém apenas os dígitos e limita o resultado a 11 dígitos. +Remove a formatação da CNH, mantém apenas os dígitos e limita o resultado a 11 dígitos. Retorna `''` quando não há nenhum dígito. ```javascript import { parseCnh } from '@brazilian-utils/brazilian-utils'; @@ -1130,49 +1233,60 @@ parseCnh('026503064-61'); // '02650306461' ## getCepInfoByAddress -Busca CEPs a partir de um endereço usando a ViaCEP. Lança `GetCepInfoByAddressValidationError` quando a UF, a cidade ou a rua estão ausentes/inválidas, `GetCepInfoByAddressNotFoundError` quando nenhum endereço corresponde à busca, e `GetCepInfoByAddressError` quando a própria ViaCEP responde com um status de erro HTTP. Uma requisição que não pode ser realizada (falha de transporte) rejeita com o erro original do `fetch`. +Busca CEPs a partir de um endereço usando a ViaCEP. Lança `GetCepInfoByAddressValidationError` quando a UF, a cidade ou a rua estão ausentes/inválidas — inclusive quando o argumento não é um objeto (omitido, `null`, uma string) e quando `federalUnit` não é uma string, casos em que nenhum `TypeError` cru escapa — `GetCepInfoByAddressNotFoundError` quando nenhum endereço corresponde à busca, e `GetCepInfoByAddressError` quando a própria ViaCEP responde com um status de erro HTTP. Uma requisição que não pode ser realizada (falha de transporte) rejeita com o erro original do `fetch`. Cada item é tipado como `CepAddressInfo` e traz a resposta da ViaCEP sem alterações, com os nomes de campo da própria ViaCEP: `cep`, `logradouro`, `complemento`, `unidade`, `bairro`, `localidade`, `uf`, `estado`, `regiao`, `ibge`, `gia`, `ddd` e `siafi`. Um nome de rua abrangente corresponde a muitos CEPs, então busque de forma tão específica quanto o endereço permitir. ```javascript import { getCepInfoByAddress } from '@brazilian-utils/brazilian-utils'; const ceps = await getCepInfoByAddress({ - federalUnit: 'SP', - city: 'Sao Paulo', - street: 'Avenida Paulista' + federalUnit: 'MG', + city: 'Ouro Preto', + street: 'Rua Direita' }); // [ // { -// cep: '01310100', -// logradouro: 'Avenida Paulista', -// complemento: 'lado par', -// bairro: 'Bela Vista', -// localidade: 'São Paulo', -// uf: 'SP' +// cep: '35411-152', +// logradouro: 'Rua Direita', +// complemento: '', +// unidade: '', +// bairro: 'Riacho (Amarantina)', +// localidade: 'Ouro Preto', +// uf: 'MG', +// estado: 'Minas Gerais', +// regiao: 'Sudeste', +// ibge: '3146107', +// gia: '', +// ddd: '31', +// siafi: '4921' // } // ] ``` ## generateProcessoJuridico -Gera um número de processo jurídico válido de acordo com a definição do [CNJ](https://atos.cnj.jus.br/atos/detalhar/119). `year` deve estar entre o ano atual e 9999, `court` entre 1 e 9; valores fora do intervalo retornam `null`. Usa `Math.random()` internamente, então não é criptograficamente seguro. +Gera um número de processo jurídico válido de acordo com a definição do [CNJ](https://atos.cnj.jus.br/atos/detalhar/119). `year` deve estar entre o ano atual e 9999, `court` entre 1 e 9; valores fora do intervalo retornam `null`. O órgão (`J`) e o tribunal (`TR`) são sorteados das listas fechadas do art. 1º, § 4º e § 5º, então o par sempre nomeia um tribunal que existe: `court` escolhe o órgão e o `TR` é sorteado entre os tribunais que aquele órgão tem. A unidade de origem (`OOOO`) é sorteada livremente, já que a resolução não publica lista central para ela. Usa `Math.random()` internamente, então não é criptograficamente seguro. ```javascript import { generateProcessoJuridico } from '@brazilian-utils/brazilian-utils'; -generateProcessoJuridico(); // '89478643020269670326' -generateProcessoJuridico({ year: 2026, court: 5 }); // string | null +generateProcessoJuridico(); // '89478645020266070326' +generateProcessoJuridico({ year: 2026, court: 5 }); // '98412562120265087260' (Justiça do Trabalho, TRT da 8ª Região) generateProcessoJuridico({ year: 10000 }); // null (ano fora do intervalo) +generateProcessoJuridico({ court: 10 }); // null (órgão inexistente) ``` ## formatLegalNature -Formata um código de natureza jurídica. +Formata um código de natureza jurídica. `options.pad` (parte de `FormatLegalNatureOptions`) funciona exatamente como em `formatCpf`/`formatCep`: com o padrão `false` a máscara é aplicada progressivamente, até onde o valor vai; com `true` o valor é primeiro completado com zeros à esquerda até os 4 dígitos de um código completo. Use `isValidLegalNature` para verificar um código. ```javascript import { formatLegalNature } from '@brazilian-utils/brazilian-utils'; formatLegalNature('2062'); // 206-2 +formatLegalNature(2062); // 206-2 +formatLegalNature('206'); // 206 (máscara aplicada até onde o valor vai) +formatLegalNature('62', { pad: true }); // 006-2 (completado até 4 dígitos antes) ``` ## isValidLegalNature @@ -1188,7 +1302,7 @@ isValidLegalNature('9999'); // false ## generateLegalNature -Gera um código de natureza jurídica válido aleatório. +Gera um código de natureza jurídica válido aleatório. Usa `Math.random()` internamente, então não é criptograficamente seguro. ```javascript import { generateLegalNature } from '@brazilian-utils/brazilian-utils'; @@ -1218,20 +1332,45 @@ const legalNatures = getLegalNatures(); legalNatures['2062']; // 'Sociedade Empresária Limitada' ``` +## getLegalNaturesByCategory + +Retorna todas as naturezas jurídicas de uma categoria do CONCLA, o grupo dado pelo primeiro dígito do código: `1` Administração Pública, `2` Entidades Empresariais, `3` Entidades sem Fins Lucrativos, `4` Pessoas Físicas e `5` Organizações Internacionais e Outras Instituições Extraterritoriais. A categoria é aceita como string ou como número, as entradas voltam ordenadas por código e uma categoria desconhecida devolve `[]`. + +```javascript +import { getLegalNaturesByCategory } from '@brazilian-utils/brazilian-utils'; + +getLegalNaturesByCategory('4')[0]; +// { +// code: '4014', +// description: 'Empresa Individual Imobiliária', +// category: { code: '4', description: 'Pessoas Físicas' }, +// } +getLegalNaturesByCategory(4).length; // 6 +getLegalNaturesByCategory('2').length; // 33 +getLegalNaturesByCategory('9'); // [] +``` + ## getLegalNature -Busca um código de natureza jurídica na tabela oficial do IBGE/CONCLA. +Busca um código de natureza jurídica na tabela oficial do IBGE/CONCLA. A entrada também traz a categoria do CONCLA em que o código está listado, dada pelo seu primeiro dígito. Nenhum código de natureza jurídica começa com zero, esse primeiro dígito é a categoria (1 a 5), então aqui nada é completado: um número e a string dos mesmos dígitos são lidos de forma idêntica. ```javascript import { getLegalNature } from '@brazilian-utils/brazilian-utils'; -getLegalNature('2062'); // { code: '2062', description: 'Sociedade Empresária Limitada' } +getLegalNature('2062'); +// { +// code: '2062', +// description: 'Sociedade Empresária Limitada', +// category: { code: '2', description: 'Entidades Empresariais' }, +// } +getLegalNature('206-2')?.code; // '2062' +getLegalNature(206.2)?.category.description; // 'Entidades Empresariais' getLegalNature('0000'); // null ``` ## generatePhone -Gera um telefone brasileiro aleatório. Aceita `'mobile'`, `'landline'` ou `'service'` (tipado como `GeneratePhoneType`); um número de serviço não tem DDD. Se omitido, gera aleatoriamente um celular ou um fixo, nunca um número de serviço. +Gera um telefone brasileiro aleatório. Aceita `'mobile'`, `'landline'` ou `'service'` (tipado como `GeneratePhoneType`); um número de serviço não tem DDD. Se omitido, gera aleatoriamente um celular ou um fixo, nunca um número de serviço. Um celular gerado sempre começa com 9, então passa nas duas regras de numeração do `isValidMobilePhone`. ```javascript import { generatePhone } from '@brazilian-utils/brazilian-utils'; @@ -1244,7 +1383,7 @@ generatePhone('service'); // '08001234567' ou '40041234' ## formatLicensePlate -Formata uma placa. Placas antigas brasileiras (`LLLNNNN`) são retornadas com hífen e placas Mercosul (`LLLNLNN`) permanecem normalizadas. +Formata uma placa. Placas antigas brasileiras (`LLLNNNN`) são retornadas com hífen e placas Mercosul (`LLLNLNN`) permanecem normalizadas. Valores parciais são formatados até onde os caracteres informados alcançarem, então também pode ser usada como máscara de digitação, e um valor que não pode iniciar uma placa válida retorna `''`. ```javascript import { formatLicensePlate } from '@brazilian-utils/brazilian-utils'; @@ -1255,7 +1394,7 @@ formatLicensePlate('abc1d23'); // 'ABC1D23' ## generateLicensePlate -Gera uma placa aleatória no formato escolhido. +Gera uma placa aleatória no formato escolhido. Usa `Math.random()` internamente, então não é criptograficamente seguro. ```javascript import { generateLicensePlate } from '@brazilian-utils/brazilian-utils'; @@ -1264,6 +1403,8 @@ generateLicensePlate(); // 'ABC1D23' (Mercosul, o padrão) generateLicensePlate('LLLNNNN'); // 'ABC1234' ``` +Uma string `format` fora dos dois literais suportados não é rejeitada: ela é usada literalmente, caractere a caractere, com `L` produzindo uma letra e qualquer outra posição um dígito. Assim, `generateLicensePlate('LLLNNLN')` devolve uma placa na sequência de motocicleta que foi retirada, que o próprio `isValidLicensePlate` rejeita; `generateLicensePlate('bogus')` devolve cinco dígitos; e `generateLicensePlate('')` devolve uma string vazia. Apenas um valor que não seja string recai no padrão Mercosul. Esse é o comportamento da versão 2.3.0, mantido para as pessoas que chamam a função em JavaScript, onde o tipo do TypeScript não alcança. + ## getFormatLicensePlate Detecta o formato normalizado de uma placa. @@ -1304,7 +1445,7 @@ convertLicensePlateToMercosul('ABC1D23'); // '' (já está no formato Mercosul) ## generatePis -Gera um PIS válido aleatório. +Gera um PIS válido aleatório. Usa `Math.random()` internamente, então não é criptograficamente seguro. ```javascript import { generatePis } from '@brazilian-utils/brazilian-utils'; @@ -1314,7 +1455,7 @@ generatePis(); // '91077906857' ## getMunicipality -Busca informações de município por código IBGE, ou obtém o código IBGE a partir do nome do município e UF. Uma única função cobre as duas direções, dependendo se `options` tem `code` ou `municipalityName`/`uf`. `code` aceita tanto `string` quanto `number` e deve ter exatamente 7 dígitos, caso contrário a função resolve para `null`. Um `code` informado como número precisa ser um inteiro não negativo: sinal e ponto decimal não são dígitos, então `-3550308` e `355030.8` resolvem para `null` em vez de serem lidos como `3550308`. A resolução é totalmente offline, a partir de um dataset do IBGE embutido na biblioteca: nenhuma requisição de rede é feita. A comparação do nome do município ignora acentos e diferenças entre maiúsculas/minúsculas. Um município desconhecido, uma UF desconhecida ou uma entrada inválida resolvem para `null`. +Busca informações de município por código IBGE, ou obtém o código IBGE a partir do nome do município e UF. **Obsoleta:** use `getMunicipalityByCode` no lugar, que é síncrona e offline; casar um município pelo nome fica a cargo da aplicação, sobre `getMunicipalities`. Uma única função cobre as duas direções, dependendo se `options` tem `code` ou `municipalityName`/`uf`. `code` aceita tanto `string` quanto `number` e deve ter exatamente 7 dígitos, caso contrário a função resolve para `null`. Um `code` informado como número precisa ser um inteiro não negativo: sinal e ponto decimal não são dígitos, então `-3550308` e `355030.8` resolvem para `null` em vez de serem lidos como `3550308`. A resolução é totalmente offline, a partir de um dataset do IBGE embutido na biblioteca: nenhuma requisição de rede é feita. A comparação do nome do município ignora acentos e diferenças entre maiúsculas/minúsculas, e toda sequência de espaços vira um único espaço, então `'sao paulo'` corresponde a `'São Paulo'`, enquanto um nome escrito sem o espaço não; a caixa é convertida para maiúsculas, a direção em que o Unicode expande `'ß'` para `'SS'`, então `'Paßos'` corresponde a `'Passos'`. Um município desconhecido, uma UF desconhecida ou uma entrada inválida resolvem para `null`. O par `[name, uf]` é um array novo a cada chamada, então alterar o resultado nunca afeta as buscas seguintes. ```javascript import { getMunicipality } from '@brazilian-utils/brazilian-utils'; @@ -1335,9 +1476,32 @@ await getMunicipality({ code: '123' }); // null (não tem 7 dígitos) ``` +Em TypeScript o tipo de retorno acompanha a direção da busca: uma consulta `{ code }` resolve para `[string, string] | null`, uma consulta `{ municipalityName, uf }` resolve para `string | null`, e uma consulta cuja direção só é conhecida em tempo de execução (uma variável tipada como `GetMunicipalityOptions`) resolve para a união das duas. + +```typescript +import { + getMunicipality, + type GetMunicipalityByCodeOptions, + type GetMunicipalityByNameOptions, + type GetMunicipalityOptions, +} from '@brazilian-utils/brazilian-utils'; + +const byCode: GetMunicipalityByCodeOptions = { code: '3550308' }; +const byName: GetMunicipalityByNameOptions = { municipalityName: 'sao paulo', uf: 'sp' }; + +await getMunicipality(byCode); +// Promise<[string, string] | null> + +await getMunicipality(byName); +// Promise + +const lookUp = (options: GetMunicipalityOptions) => getMunicipality(options); +// (options: GetMunicipalityOptions) => Promise<[string, string] | string | null> +``` + ## getMunicipalities -Retorna os municípios brasileiros publicados pelo IBGE. Retorna todos os municípios se nenhum estado for fornecido, ou os municípios de um estado específico. Cada município é retornado como `{ code, name, stateCode }`, onde `code` é o código IBGE de 7 dígitos do município. Os resultados são ordenados por nome com `localeCompare` no locale "pt-BR". Cada chamada retorna um array novo com objetos novos, então alterar o resultado nunca afeta chamadas seguintes. Um código de estado desconhecido retorna um array vazio em vez de lançar erro. +Retorna os municípios brasileiros publicados pelo IBGE. Retorna todos os municípios se nenhum estado for fornecido, ou os municípios de um estado específico. Cada município é retornado como `{ code, name, stateCode }`, onde `code` é o código IBGE de 7 dígitos do município. Os resultados são ordenados por nome com `localeCompare` no locale "pt-BR". Cada chamada retorna um array novo com objetos novos, então alterar o resultado nunca afeta chamadas seguintes. Um código de estado desconhecido retorna um array vazio em vez de lançar erro. Só um `stateCode` omitido (ou `undefined`) pede a lista completa: `getMunicipalities(null)` e `getMunicipalities('')` retornam `[]`, enquanto os mais permissivos `getCities(null)` e `getCities('')` retornam todas as cidades. O código do estado é comparado exatamente, inclusive na caixa: `getMunicipalities('sp')` retorna `[]` enquanto `getMunicipalities('SP')` retorna os 645 municípios paulistas. `getMunicipalities` e `getCities` são as únicas buscas por estado sensíveis à caixa; `getStateNameByCode`, `getTimezoneByState`, `getAreaCodesByState` e `getMunicipality` ignoram a caixa. ```javascript import { getMunicipalities } from '@brazilian-utils/brazilian-utils'; @@ -1385,10 +1549,9 @@ getMunicipalityByCode(3550308); getMunicipalityByCode('0000000'); // null (código desconhecido) getMunicipalityByCode('123'); // null (não tem 7 dígitos) ``` - ## isHoliday -Verifica se uma data específica é feriado brasileiro. A verificação compara a data local do `targetDate` (ano/mês/dia lidos localmente), não seu instante UTC subjacente. Retorna `false` quando `targetDate` está ausente ou não é um `Date` válido. +Verifica se uma data específica é feriado brasileiro. A verificação compara a data local do `targetDate` (ano/mês/dia lidos localmente), não seu instante UTC subjacente. Retorna `false` quando `targetDate` está ausente ou não é um `Date` válido. Um `stateCode` inválido é tratado de duas formas diferentes: uma string que não é um código de estado conhecido é ignorada e só os feriados nacionais são considerados, igual ao `getHolidays`, enquanto um `stateCode` presente que não é uma string (um número, `null`, um objeto) é rejeitado e faz a chamada retornar `false` mesmo em um feriado nacional. ```javascript import { isHoliday } from '@brazilian-utils/brazilian-utils'; @@ -1400,7 +1563,7 @@ isHoliday(); // false ## isBusinessDay -Verifica se uma data é um dia útil no Brasil. Retorna `false` para sábados, domingos e feriados brasileiros retornados por `getHolidays` para a data local de `value` (ano/mês/dia lidos localmente), a mesma convenção usada por `isHoliday`. `options.includeOptional` (parte de `IsBusinessDayOptions`) tem valor padrão `true`, então feriados do tipo opcional (`Holiday.type === "optional"`, ou seja, Carnaval e Corpus Christi) também contam como dias não úteis; passe `false` para considerar apenas os feriados estatutários. `options.stateCode` também considera os feriados daquele estado; um `stateCode` desconhecido/inválido é ignorado, retornando apenas os feriados nacionais. Um `value` que não é um `Date` válido retorna `false`. Só os anos de 1900 a 2099 são suportados, o intervalo que `getHolidays` calcula; uma data fora dele retorna `false`. +Verifica se uma data é um dia útil no Brasil. Retorna `false` para sábados, domingos e feriados brasileiros retornados por `getHolidays` para a data local de `value` (ano/mês/dia lidos localmente), a mesma convenção usada por `isHoliday`. `options.includeOptional` (parte de `BusinessDayOptions`, o tipo de opções que todos os utilitários de dias úteis compartilham) tem valor padrão `true`, então feriados do tipo opcional (`Holiday.type === "optional"`, ou seja, Carnaval e Corpus Christi) também contam como dias não úteis; passe `false` para considerar apenas os feriados estatutários. `options.stateCode` também considera os feriados daquele estado; uma string que não é um código de estado conhecido é ignorada, considerando apenas os feriados nacionais, enquanto um `stateCode` presente que não é uma string (um número, `null`, um objeto) é rejeitado e faz a chamada retornar `false` mesmo em um dia de semana comum — a mesma distinção que `isHoliday` faz, e o valor que `addBusinessDays`, `subBusinessDays` e `differenceInBusinessDays` rejeitam com `null`. Um `value` que não é um `Date` válido retorna `false`. Só os anos de 1900 a 2099 são suportados, o intervalo que `getHolidays` calcula; uma data fora dele retorna `false`. ```javascript import { isBusinessDay } from '@brazilian-utils/brazilian-utils'; @@ -1417,38 +1580,55 @@ isBusinessDay(new Date('not a date')); // false ## addBusinessDays -Adiciona um número de dias úteis brasileiros a uma data, pulando sábados, domingos e feriados brasileiros exatamente como `isBusinessDay` os define (mesmas opções `stateCode`/`includeOptional`). Retorna um novo `Date`; a `date` de entrada (parte de `AddBusinessDaysParams`) nunca é alterada, e seu horário é preservado no resultado. `days: 0` retorna um novo `Date` igual a `date`, sem alterações, mesmo quando `date` cai em um fim de semana ou feriado, isso reflete o comportamento verificado de [`addBusinessDays(date, 0)` do date-fns](https://date-fns.org/docs/addBusinessDays), que também não avança a entrada para o próximo dia útil. Um `days` negativo anda para trás, um dia útil por vez, também como no date-fns. Retorna `null` em caso de entrada inválida: uma `date` que não é um `Date` válido, um `days` que não é um número inteiro finito, ou um `stateCode` que não é uma string. Só os anos de 1900 a 2099 são suportados, o intervalo que `getHolidays` calcula; uma data fora dele (ou, no `addBusinessDays`, um percurso que sai dele) retorna `null`. +Adiciona um número de dias úteis brasileiros a uma data, pulando sábados, domingos e feriados brasileiros exatamente como `isBusinessDay` os define (as mesmas `BusinessDayOptions`: `options.includeOptional`, padrão `true`, e `options.stateCode` funcionam exatamente como lá). A assinatura é a do date-fns: `addBusinessDays(date, amount, options?)`. Retorna um novo `Date`; a `date` de entrada nunca é alterada, e seu horário é preservado no resultado. Um `amount` igual a `0` retorna um novo `Date` igual a `date`, sem alterações, mesmo quando `date` cai em um fim de semana ou feriado, isso reflete o comportamento verificado de [`addBusinessDays(date, 0)` do date-fns](https://date-fns.org/docs/addBusinessDays), que também não avança a entrada para o próximo dia útil. Um `amount` negativo anda para trás, um dia útil por vez, também como no date-fns. Retorna `null` em caso de entrada inválida: uma `date` que não é um `Date` válido, um `amount` que não é um número inteiro finito, ou um `stateCode` que não é uma string; um `options` que não é um objeto é ignorado, exatamente como o `isBusinessDay` o ignora. Só os anos de 1900 a 2099 são suportados, o intervalo que `getHolidays` calcula; uma data fora dele, ou um percurso que sai dele, retorna `null`. ```javascript import { addBusinessDays } from '@brazilian-utils/brazilian-utils'; -addBusinessDays({ date: new Date(2024, 0, 2, 12), days: 1 }); // Date, 2024-01-03 12:00 (o dia seguinte já é útil) -addBusinessDays({ date: new Date(2024, 11, 31, 12), days: 1 }); // Date, 2025-01-02 12:00 (2025-01-01 é Ano novo, pulado) -addBusinessDays({ date: new Date(2024, 0, 5, 12), days: -1 }); // Date, 2024-01-04 12:00 (anda para trás) -addBusinessDays({ date: new Date(2024, 0, 6, 12), days: 0 }); // Date, 2024-01-06 12:00 (sem alteração, mesmo sendo sábado) -addBusinessDays({ date: new Date(2024, 6, 8, 12), days: 1, stateCode: 'SP' }); // Date, 2024-07-10 12:00 (2024-07-09 é a Revolução Constitucionalista em SP, pulado) -addBusinessDays({ date: new Date('not a date'), days: 1 }); // null -addBusinessDays({ date: new Date(2024, 0, 2), days: 1.5 }); // null (não é um número inteiro) +addBusinessDays(new Date(2024, 0, 2, 12), 1); // Date, 2024-01-03 12:00 (o dia seguinte já é útil) +addBusinessDays(new Date(2024, 11, 31, 12), 1); // Date, 2025-01-02 12:00 (2025-01-01 é Ano novo, pulado) +addBusinessDays(new Date(2024, 0, 5, 12), -1); // Date, 2024-01-04 12:00 (anda para trás) +addBusinessDays(new Date(2024, 0, 6, 12), 0); // Date, 2024-01-06 12:00 (sem alteração, mesmo o sábado não sendo dia útil) +addBusinessDays(new Date(2024, 6, 8, 12), 1, { stateCode: 'SP' }); // Date, 2024-07-10 12:00 (2024-07-09 é a Revolução Constitucionalista em SP, pulado) +addBusinessDays(new Date('not a date'), 1); // null +addBusinessDays(new Date(2024, 0, 2), 1.5); // null (não é um número inteiro) +``` + +## subBusinessDays + +Subtrai um número de dias úteis brasileiros de uma data: `subBusinessDays(date, amount, options?)` é `addBusinessDays(date, -amount, options)`, e é exatamente assim que a função é implementada, então tudo o que vale acima vale aqui (o horário preservado, a entrada intacta, um `amount` igual a `0` devolvendo a data sem alterações, o intervalo de 1900 a 2099 e os casos de `null`), inclusive o `options.stateCode` e o `options.includeOptional`. Um `amount` negativo anda para frente. + +```javascript +import { subBusinessDays } from '@brazilian-utils/brazilian-utils'; + +subBusinessDays(new Date(2024, 0, 5, 12), 1); // Date, 2024-01-04 12:00 (o dia anterior já é útil) +subBusinessDays(new Date(2024, 0, 8, 12), 1); // Date, 2024-01-05 12:00 (anda para trás passando pelo fim de semana) +subBusinessDays(new Date(2025, 0, 2, 12), 1); // Date, 2024-12-31 12:00 (2025-01-01 é Ano novo, pulado) +subBusinessDays(new Date(2024, 0, 5, 12), -1); // Date, 2024-01-08 12:00 (anda para frente) +subBusinessDays(new Date(2024, 0, 6, 12), 0); // Date, 2024-01-06 12:00 (sem alteração, mesmo o sábado não sendo dia útil) +subBusinessDays(new Date(2024, 6, 10, 12), 1, { stateCode: 'SP' }); // Date, 2024-07-08 12:00 (2024-07-09 é a Revolução Constitucionalista em SP, pulado) +subBusinessDays(new Date('not a date'), 1); // null +subBusinessDays(new Date(2024, 0, 2), 1.5); // null (não é um número inteiro) ``` ## differenceInBusinessDays -Conta o número de dias úteis brasileiros entre duas datas, refletindo a semântica de [`differenceInBusinessDays` do date-fns](https://date-fns.org/docs/differenceInBusinessDays) (verificada em seu código-fonte): `params.from` é contado quando ele próprio é um dia útil, `params.to` nunca é contado, e cada dia útil estritamente entre os dois é contado uma vez. Só a data de calendário de cada `Date` importa, o horário é ignorado. Os dias úteis são determinados exatamente como em `isBusinessDay` (mesmas opções `stateCode`/`includeOptional`). `from`/`to` no mesmo dia de calendário retornam `0`; um `to` anterior a `from` retorna um número negativo. Retorna `null` em caso de entrada inválida: um `from`/`to` que não é um `Date` válido, ou um `stateCode` que não é uma string. Os parâmetros são tipados como `DifferenceInBusinessDaysParams`. Só os anos de 1900 a 2099 são suportados, o intervalo que `getHolidays` calcula; uma data fora dele (ou, no `addBusinessDays`, um percurso que sai dele) retorna `null`. +Conta o número de dias úteis brasileiros entre duas datas, refletindo a semântica de [`differenceInBusinessDays` do date-fns](https://date-fns.org/docs/differenceInBusinessDays) (verificada em seu código-fonte), inclusive a ordem dos argumentos: `differenceInBusinessDays(laterDate, earlierDate, options?)`. O percurso começa em `earlierDate` e para logo antes de `laterDate`, então `earlierDate` é contado quando ele próprio é um dia útil, `laterDate` nunca é contado, e cada dia útil estritamente entre os dois é contado uma vez. Só a data de calendário de cada `Date` importa, o horário é ignorado. Os dias úteis são determinados exatamente como em `isBusinessDay` (as mesmas `BusinessDayOptions`), inclusive o `options.includeOptional` (padrão `true`) e o `options.stateCode`. O resultado é positivo quando `laterDate` é posterior a `earlierDate` e negativo quando é anterior; duas datas no mesmo dia de calendário retornam `0`. Retorna `null` em caso de entrada inválida: uma data que não é um `Date` válido, ou um `stateCode` que não é uma string; um `options` que não é um objeto é ignorado. Só os anos de 1900 a 2099 são suportados, o intervalo que `getHolidays` calcula; uma data fora dele retorna `null`. ```javascript import { differenceInBusinessDays } from '@brazilian-utils/brazilian-utils'; -differenceInBusinessDays({ from: new Date(2024, 0, 1), to: new Date(2024, 0, 2) }); // 0 (01/01 é Ano novo) -differenceInBusinessDays({ from: new Date(2024, 0, 2), to: new Date(2024, 0, 3) }); // 1 (02/01 contado, uma terça-feira) -differenceInBusinessDays({ from: new Date(2024, 0, 3), to: new Date(2024, 0, 2) }); // -1 (to anterior a from) -differenceInBusinessDays({ from: new Date(2024, 0, 2), to: new Date(2024, 0, 2) }); // 0 (mesmo dia) -differenceInBusinessDays({ from: new Date(2024, 6, 8), to: new Date(2024, 6, 10), stateCode: 'SP' }); // 1 (09/07/2024 é feriado estadual em SP) -differenceInBusinessDays({ from: new Date('not a date'), to: new Date() }); // null +differenceInBusinessDays(new Date(2024, 0, 2), new Date(2024, 0, 1)); // 0 (01/01 é Ano novo, não contado) +differenceInBusinessDays(new Date(2024, 0, 3), new Date(2024, 0, 2)); // 1 (02/01 contado, uma terça-feira; 03/01 não) +differenceInBusinessDays(new Date(2024, 0, 2), new Date(2024, 0, 3)); // -1 (a data posterior vem primeiro, então a contagem é negativa) +differenceInBusinessDays(new Date(2024, 0, 2), new Date(2024, 0, 2)); // 0 (mesmo dia) +differenceInBusinessDays(new Date(2024, 6, 10), new Date(2024, 6, 8), { stateCode: 'SP' }); // 1 (09/07/2024 é feriado estadual em SP) +differenceInBusinessDays(new Date(), new Date('not a date')); // null ``` ## convertDateToWords -Formata uma data por extenso em português do Brasil, ex.: `"01/01/2024"` vira `"primeiro de janeiro de dois mil e vinte e quatro"`. Aceita um `Date` (lido pela sua data de calendário local, a mesma convenção usada por `isHoliday`) ou uma string no formato `"dd/mm/yyyy"` ou ISO `"yyyy-mm-dd"`. Com o `options.style` padrão `"full"`, o dia 1 é escrito como "primeiro" e os demais dias usam o número cardinal; com `"month"`, só o nome do mês é escrito por extenso e o dia/ano ficam em dígitos (o dia 1 como `"1º"`, ex.: `"2 de março de 2024"`, `"1º de janeiro de 2024"`). Os nomes dos meses ficam em minúsculo. No estilo `"full"` o ano é escrito por extenso sem a vírgula de milhar que `convertNumberToWords`/`convertCurrencyToWords` usam (`1999` vira `"mil novecentos e noventa e nove"`, não `"mil, novecentos e noventa e nove"`), do jeito que uma data é lida em voz alta. `options.weekday` (padrão `false`) prefixa o nome do dia da semana em pt-BR minúsculo seguido de vírgula (`"sábado, dois de março de dois mil e vinte e quatro"`), calculado a partir da data de calendário resolvida. `options.case` define a caixa de todo o resultado: `"lower"` (padrão), `"sentence"` (só a primeira letra em maiúscula) ou `"upper"` (tudo em maiúscula, preservando os acentos). Valores inválidos de `case`/`style` são ignorados e o padrão é usado. O dia 29 de fevereiro é aceito nos anos bissextos do calendário gregoriano proléptico (divisíveis por 4, exceto séculos não divisíveis por 400). Retorna `""` para um `Date` inválido, uma string malformada, um dia/mês que não existe ou uma data anterior ao ano 1. +Formata uma data por extenso em português do Brasil, ex.: `"01/01/2024"` vira `"primeiro de janeiro de dois mil e vinte e quatro"`. Aceita um `Date` (lido pela sua data de calendário local, a mesma convenção usada por `isHoliday`) ou uma string no formato `"dd/mm/yyyy"` ou ISO `"yyyy-mm-dd"`. Com o `options.style` padrão `"full"`, o dia 1 é escrito como "primeiro" e os demais dias usam o número cardinal; com `"month"`, só o nome do mês é escrito por extenso e o dia/ano ficam em dígitos (o dia 1 como `"1º"`, ex.: `"2 de março de 2024"`, `"1º de janeiro de 2024"`). Os nomes dos meses ficam em minúsculo. No estilo `"full"` o ano é escrito por extenso sem a vírgula de milhar que `convertNumberToWords`/`convertCurrencyToWords` usam (`1999` vira `"mil novecentos e noventa e nove"`, não `"mil novecentos e noventa e nove"`), do jeito que uma data é lida em voz alta. `options.weekday` (padrão `false`) prefixa o nome do dia da semana em pt-BR minúsculo seguido de vírgula (`"sábado, dois de março de dois mil e vinte e quatro"`), calculado a partir da data de calendário resolvida. Um valor inválido de `style` é ignorado e o padrão é usado. O resultado sai sempre em minúsculas; aplique qualquer outra caixa por conta própria. O dia 29 de fevereiro é aceito nos anos bissextos do calendário gregoriano proléptico (divisíveis por 4, exceto séculos não divisíveis por 400). Retorna `""` para um `Date` inválido, uma string malformada, um dia/mês que não existe ou uma data anterior ao ano 1. ```javascript import { convertDateToWords } from '@brazilian-utils/brazilian-utils'; @@ -1456,7 +1636,6 @@ import { convertDateToWords } from '@brazilian-utils/brazilian-utils'; convertDateToWords('01/01/2024'); // "primeiro de janeiro de dois mil e vinte e quatro" convertDateToWords('2024-01-02'); // "dois de janeiro de dois mil e vinte e quatro" convertDateToWords(new Date(2024, 0, 1)); // "primeiro de janeiro de dois mil e vinte e quatro" -convertDateToWords('01/01/2024', { case: 'sentence' }); // "Primeiro de janeiro de dois mil e vinte e quatro" convertDateToWords('02/03/2024', { style: 'month' }); // "2 de março de 2024" convertDateToWords('01/01/2024', { style: 'month' }); // "1º de janeiro de 2024" convertDateToWords('02/03/2024', { weekday: true }); // "sábado, dois de março de dois mil e vinte e quatro" @@ -1479,7 +1658,7 @@ formatVoterId('1234567880191'); // '1234 5678 8 01 91' (título de 13 dígitos S ## isValidVoterId -Valida se um título de eleitor é válido. Aceita tanto o título padrão de 12 dígitos quanto o título de 13 dígitos emitido por São Paulo (UF `01`) e Minas Gerais (UF `02`). +Valida se um título de eleitor é válido. Aceita tanto o título padrão de 12 dígitos quanto o título de 13 dígitos emitido por São Paulo (UF `01`) e Minas Gerais (UF `02`). Espaços e pontos são aceitos ao redor e entre os grupos `0000 0000 00 00`, mas qualquer outro caractere, uma letra em especial, invalida o valor. ```javascript import { generateVoterId, isValidVoterId } from '@brazilian-utils/brazilian-utils'; @@ -1514,34 +1693,47 @@ parseVoterId('1234 5678 8 01 91'); // '1234567880191' (título de 13 dígitos SP ## isValidCns -Verifica se um número de CNS (Cartão Nacional de Saúde) é válido, o identificador único do usuário do SUS (Sistema Único de Saúde). Cartões definitivos (iniciados em 1 ou 2) são validados com a mesma ponderação módulo 11 usada no PIS sobre uma base de 11 dígitos embutida, ajustando a base em +2 quando o dígito verificador bruto resulta em 10. Cartões provisórios (iniciados em 7, 8 ou 9) são validados por uma soma ponderada única (pesos de 15 a 1) que deve ser múltipla de 11. O valor precisa vir escrito como os 15 dígitos, opcionalmente separados nos grupos impressos de 3-4-4-4 por espaços ou pelos caracteres de máscara usuais; letras no meio dos dígitos são rejeitadas em vez de ignoradas. +Verifica se um número de CNS (Cartão Nacional de Saúde) é válido, o identificador único do usuário do SUS (Sistema Único de Saúde). Cartões definitivos (iniciados em 1 ou 2) são validados sobre uma base embutida de 11 dígitos derivada do PIS/PASEP/NIS, ponderada de 15 até 5; quando o dígito bruto resulta em 10, o DATASUS soma 2 à soma ponderada, recalcula o dígito e marca o cartão com o sufixo `001` em vez de `000`. Cartões provisórios (iniciados em 7, 8 ou 9) são validados por uma soma ponderada única (pesos de 15 a 1) que deve ser múltipla de 11. O valor precisa vir escrito como os 15 dígitos, opcionalmente separados nos grupos impressos de 3-4-4-4 por espaço em branco, `.`, `-` ou `/`, os caracteres de máscara intercambiáveis que `isValidCpf` e `isValidCnpj` aceitam, inclusive uma sequência deles entre dois grupos; letras no meio dos dígitos, ou um separador dentro de um grupo, são rejeitadas em vez de ignoradas. + +As duas rotinas vêm da [página de validação de CNS da ANVISA](https://rni-docs.anvisa.gov.br/docs/regras_gerais/validacoes/validacaoCNS/), que fica atrás de um filtro de bots e responde HTTP 403 a clientes que não sejam navegadores. A [página do e-SUS APS](https://integracao.esusab.ufsc.br/ledi/documentacao/regras/algoritmo_CNS.html) documenta o mesmo algoritmo e é acessível sem navegador, mas aplica a rotina de provisórios a números iniciados em 5, 7, 8 ou 9; esta implementação segue a ANVISA e rejeita um número iniciado em 5 mesmo quando a soma ponderada fecha. ```javascript import { isValidCns } from '@brazilian-utils/brazilian-utils'; isValidCns('123456789010000'); // true (definitivo) isValidCns('700000000000005'); // true (provisório) +isValidCns('123.4567-8901/0000'); // true (qualquer um dos caracteres de máscara) isValidCns('12345678901'); // false (tamanho inválido) isValidCns('abc123456789010000'); // false (não escrito como um CNS) ``` ## formatCns -Formata um número de CNS (Cartão Nacional de Saúde) nos grupos de exibição usuais de 3-4-4-4 dígitos separados por espaço. As opções são tipadas como `FormatCnsOptions`. +Formata um número de CNS (Cartão Nacional de Saúde) nos grupos de exibição usuais de 3-4-4-4 dígitos separados por espaço. `options.pad` (parte de `FormatCnsOptions`) preenche o valor com zeros à esquerda até as 15 posições do padrão antes de aplicar a máscara (padrão `false`). ```javascript import { formatCns } from '@brazilian-utils/brazilian-utils'; -formatCns('123456789010001'); // '123 4567 8901 0001' -formatCns(123456789010001); // '123 4567 8901 0001' +formatCns('123456789010000'); // '123 4567 8901 0000' +formatCns(123456789010000); // '123 4567 8901 0000' formatCns('89010001', { pad: true }); // '000 0000 8901 0001' ``` +## parseCns + +Remove a formatação do CNS (Cartão Nacional de Saúde), mantém apenas os dígitos e limita o resultado a 15 dígitos. Um valor parcial passa adiante até onde vai, então também dá para tirar a máscara de um campo ainda sendo digitado; use `isValidCns` para verificar o número em si. + +```javascript +import { parseCns } from '@brazilian-utils/brazilian-utils'; + +parseCns('123 4567 8901 0000'); // '123456789010000' +``` + ## isValidCertidao -Verifica se a matrícula de uma certidão de registro civil (nascimento, casamento, óbito e os demais atos mantidos por uma serventia de registro civil das pessoas naturais) é válida. A matrícula tem 32 dígitos distribuídos em 6 (CNS da serventia) + 2 (acervo) + 2 (serviço) + 4 (ano) + 1 (tipo do livro) + 5 (livro) + 3 (folha) + 7 (termo) + 2 (dígitos verificadores), e os dois dígitos verificadores usam módulo 11 com pesos ciclando de 2 a 10 e voltando por 0. Aceita os caracteres de máscara usuais e espaços entre e ao redor dos grupos. O layout é o em vigor do [art. 473 do Código Nacional de Normas da Corregedoria Nacional de Justiça](https://atos.cnj.jus.br/atos/detalhar/5243) (Provimento CNJ nº 149/2023, na redação do Provimento CN nº 182/2024); a própria matrícula foi instituída pelo já revogado [Provimento CNJ nº 2/2009](https://atos.cnj.jus.br/atos/detalhar/1311). Os dígitos verificadores estão detalhados em [ghiorzi.org](http://ghiorzi.org/DVnew.htm) e implementado pelo [validation-br](https://github.com/klawdyo/validation-br/blob/feat-certidao/src/certidao.ts) e pelo [validator-docs](https://github.com/geekcom/validator-docs/blob/master/src/validator-docs/Rules/Certidao.php). +Verifica se a matrícula de uma certidão de registro civil (nascimento, casamento, óbito e os demais atos mantidos por uma serventia de registro civil das pessoas naturais) é válida. A matrícula tem 32 dígitos distribuídos em 6 (CNS da serventia) + 2 (acervo) + 2 (serviço) + 4 (ano) + 1 (tipo do livro) + 5 (livro) + 3 (folha) + 7 (termo) + 2 (dígitos verificadores), e os dois dígitos verificadores usam módulo 11 com os pesos ciclando de 2 a 10 e voltando por 0: o primeiro cálculo começa em 2 sobre os 30 dígitos da base, o segundo em 1 sobre os 31 dígitos que incluem o primeiro dígito verificador, e nos dois um resto 10 é lido como 1. Aceita os caracteres de máscara usuais e espaços entre e ao redor dos grupos. O layout é o publicado atualmente no [art. 473 do Código Nacional de Normas da Corregedoria Nacional de Justiça](https://atos.cnj.jus.br/atos/detalhar/5243) (Provimento CNJ nº 149/2023), com o inciso II e os §§ 1º e 3º a 5º na redação do Provimento CN nº 237/2026 e o restante do artigo, inclusive o § 2º, na do Provimento CN nº 182/2024; a própria matrícula foi instituída pelo já revogado [Provimento CNJ nº 2/2009](https://atos.cnj.jus.br/atos/detalhar/1311) e ganhou sua estrutura de dígitos no também revogado [Provimento CNJ nº 3/2009, art. 7º](https://atos.cnj.jus.br/atos/detalhar/1310). Os dígitos verificadores estão detalhados em [ghiorzi.org](http://ghiorzi.org/DVnew.htm) e implementado pelo [validation-br](https://github.com/klawdyo/validation-br/blob/feat-certidao/src/certidao.ts) e pelo [validator-docs](https://github.com/geekcom/validator-docs/blob/master/src/validator-docs/Rules/Certidao.php). -Os dígitos do serviço são fixos em `55`, o código que o [art. 473, III](https://atos.cnj.jus.br/atos/detalhar/5243) atribui ao registro civil das pessoas naturais, então uma matrícula com qualquer outro par na nona e décima posições é rejeitada por mais que os dígitos verificadores confiram. O dígito do tipo de livro sempre precisa nomear um dos nove tipos de livro (o mesmo `CertidaoType` retornado por `parseCertidao`), então uma matrícula cujo dígito é `0` é rejeitada por mais que os dígitos verificadores confiram, do mesmo jeito que `parseCertidao` devolve `null` para ela. `options.accept` (parte de `IsValidCertidaoOptions`) restringe ainda mais aos tipos listados; o padrão é aceitar todos os tipos, e um valor que não seja um array volta para esse padrão. Só uma string é aceita: os 32 dígitos de uma matrícula são mais do que um número JavaScript comporta. +Os dígitos do serviço são fixos em `55`, o código que o [art. 473, III](https://atos.cnj.jus.br/atos/detalhar/5243) atribui ao registro civil das pessoas naturais, então uma matrícula com qualquer outro par na nona e décima posições é rejeitada por mais que os dígitos verificadores confiram. O dígito do tipo de livro sempre precisa nomear um dos nove tipos de livro (o mesmo `CertidaoType` retornado por `getCertidaoInfo`), então uma matrícula cujo dígito é `0` é rejeitada por mais que os dígitos verificadores confiram, do mesmo jeito que `getCertidaoInfo` devolve `null` para ela. `options.accept` (parte de `IsValidCertidaoOptions`) restringe ainda mais aos tipos listados; o padrão é aceitar todos os tipos, e um valor que não seja um array volta para esse padrão. Só uma string é aceita: os 32 dígitos de uma matrícula são mais do que um número JavaScript comporta. ```javascript import { isValidCertidao } from '@brazilian-utils/brazilian-utils'; @@ -1555,14 +1747,38 @@ isValidCertidao('104539 01 55 2013 1 00012 021 0000123 21', { accept: ['birth'] isValidCertidao('104539 01 55 2013 1 00012 021 0000123 21', { accept: ['death'] }); // false ``` +## formatCertidao + +Formata a matrícula de uma certidão de registro civil na máscara impressa do Provimento, os 32 dígitos agrupados em 6 2 2 4 1 5 3 7 2 e separados por espaços. `options.pad` (parte de `FormatCertidaoOptions`) preenche o valor com zeros à esquerda até 32 dígitos (padrão `false`). A máscara é a do [art. 473 do Código Nacional de Normas da Corregedoria Nacional de Justiça](https://atos.cnj.jus.br/atos/detalhar/5243). Um número é aceito e lido como a string dos seus dígitos, como no `formatCpf`, mas uma matrícula completa de 32 dígitos precisa ser uma string: essa quantidade de dígitos é mais do que um número JavaScript comporta com exatidão. Em tempo de execução o valor é lido pelos seus dígitos e a máscara é aplicada até onde eles vão, como em todo formatador deste pacote, então uma matrícula parcial ainda sendo digitada é mascarada progressivamente. + +```javascript +import { formatCertidao } from '@brazilian-utils/brazilian-utils'; + +formatCertidao('10453901552013100012021000012321'); // 104539 01 55 2013 1 00012 021 0000123 21 +formatCertidao('104539.01.55.2013.1.00012.021.0000123-21'); // 104539 01 55 2013 1 00012 021 0000123 21 +formatCertidao('1552010100020112000012087', { pad: true }); // 000000 01 55 2010 1 00020 112 0000120 87 +formatCertidao(104539015520); // 104539 01 55 20 (um número é lido como a string dos seus dígitos) +``` + ## parseCertidao -Extrai os campos da matrícula de uma certidão de registro civil, retornando `null` quando a matrícula é inválida, o que inclui um código de livro que não é um dos nove livros. O [art. 473, V do Código Nacional de Normas da Corregedoria Nacional de Justiça](https://atos.cnj.jus.br/atos/detalhar/5243) lista os códigos de 1 a 7; os códigos 8 (emancipação) e 9 (interdição) vêm do Anexo IV do revogado Provimento CNJ nº 63/2017, conforme listados em [ghiorzi.org](http://ghiorzi.org/DVnew.htm), e são mantidos porque matrículas emitidas sob ele ainda circulam. Só uma string é aceita: os 32 dígitos de uma matrícula são mais do que um número JavaScript comporta. +Remove a formatação da matrícula de uma certidão de registro civil, mantém apenas os dígitos e limita o resultado a 32 dígitos. Isso só tira a máscara: use `isValidCertidao` para verificar a matrícula e `getCertidaoInfo` para ler os campos dela. ```javascript import { parseCertidao } from '@brazilian-utils/brazilian-utils'; parseCertidao('104539 01 55 2013 1 00012 021 0000123 21'); +// '10453901552013100012021000012321' +``` + +## getCertidaoInfo + +Extrai os campos da matrícula de uma certidão de registro civil, retornando `null` quando a matrícula é inválida, o que inclui um código de livro que não é um dos nove livros. Um serviço diferente do `55` que o art. 473, III fixa para o registro civil das pessoas naturais também resulta em `null`. O [art. 473, V do Código Nacional de Normas da Corregedoria Nacional de Justiça](https://atos.cnj.jus.br/atos/detalhar/5243) lista os códigos de 1 a 7; nenhum texto primário do CNJ acessível hoje publica os outros dois, inclusive o Anexo IV do revogado Provimento CNJ nº 63/2017, que lista os mesmos sete. Os códigos 8 (emancipação) e 9 (interdição) vêm das referências em que a regra do dígito verificador se apoia: o [ghiorzi.org](http://ghiorzi.org/DVnew.htm) e o [validation-br](https://github.com/klawdyo/validation-br/blob/feat-certidao/src/certidao.ts) publicam a lista dos nove livros. Eles são mantidos porque matrículas com eles circulam. Só uma string é aceita: os 32 dígitos de uma matrícula são mais do que um número JavaScript comporta. + +```javascript +import { getCertidaoInfo } from '@brazilian-utils/brazilian-utils'; + +getCertidaoInfo('104539 01 55 2013 1 00012 021 0000123 21'); // { // registryCns: '104539', // acervo: '01', @@ -1576,15 +1792,15 @@ parseCertidao('104539 01 55 2013 1 00012 021 0000123 21'); // checkDigits: '21' // } -parseCertidao('invalid'); // null +getCertidaoInfo('invalid'); // null ``` -O resultado `Certidao` traz: +O resultado `CertidaoInfo` traz: | Chave | Descrição | | --- | --- | | `registryCns` | O CNS (Código Nacional de Serventia) de 6 dígitos da serventia que lavrou o ato. | -| `acervo` | Acervo a que o livro pertence: `"01"` acervo próprio, `"02"` acervo incorporado. | +| `acervo` | Acervo a que o livro pertence: `"01"` acervo próprio, `"02"` em diante um por acervo incorporado. O [art. 473, §§ 3º a 5º](https://atos.cnj.jus.br/atos/detalhar/5243) separa os incorporados pela data em que a serventia de origem foi extinta ou desativada: até 31/12/2009 a matrícula leva o CNS da unidade incorporadora e um código de acervo a partir de `"02"`, um por incorporação; a partir de 1º/01/2010 leva o CNS da própria unidade incorporada e o código `"01"`, considerado acervo próprio dessa unidade; e um acervo fracionado entre duas ou mais serventias sucessoras leva o CNS próprio de cada sucessora com o código `"02"`. | | `service` | Serviço prestado pela serventia, sempre `"55"`, o registro civil das pessoas naturais. | | `year` | Ano do registro, com 4 dígitos. | | `type` | Livro a que o ato pertence: `"birth"`, `"marriage"`, `"religious-marriage"`, `"death"`, `"stillbirth"`, `"banns"`, `"other"`, `"emancipation"` ou `"interdiction"`. | @@ -1594,21 +1810,9 @@ O resultado `Certidao` traz: | `term` | Número do termo, com 7 dígitos e zeros à esquerda. | | `checkDigits` | Os 2 dígitos verificadores módulo 11 da matrícula. | -## formatCertidao - -Formata a matrícula de uma certidão de registro civil na máscara impressa do Provimento, os 32 dígitos agrupados em 6 2 2 4 1 5 3 7 2 e separados por espaços. `options.pad` (parte de `FormatCertidaoOptions`) preenche o valor com zeros à esquerda até 32 dígitos. A máscara é a do [art. 473 do Código Nacional de Normas da Corregedoria Nacional de Justiça](https://atos.cnj.jus.br/atos/detalhar/5243). Só uma string é aceita: os 32 dígitos de uma matrícula são mais do que um número JavaScript comporta. - -```javascript -import { formatCertidao } from '@brazilian-utils/brazilian-utils'; - -formatCertidao('10453901552013100012021000012321'); // 104539 01 55 2013 1 00012 021 0000123 21 -formatCertidao('104539.01.55.2013.1.00012.021.0000123-21'); // 104539 01 55 2013 1 00012 021 0000123 21 -formatCertidao('1552010100020112000012087', { pad: true }); // 000000 01 55 2010 1 00020 112 0000120 87 -``` - ## isValidCei -Verifica se um número de CEI (Cadastro Específico do INSS) é válido. O CEI identifica o empregador sem CNPJ, como uma obra ou um produtor rural: 12 dígitos impressos como `00.000.00000/00`, sendo o último um dígito verificador calculado sobre os 11 dígitos da base com os pesos 7, 4, 1, 8, 5, 2, 1, 6, 3, 7 e 4. Aceita os caracteres de máscara usuais e espaços entre e ao redor dos grupos. A Receita Federal não publica essa regra de dígito verificador, então ela segue as implementações de referência do [yii2-br-validator](https://github.com/yiibr/yii2-br-validator/blob/master/src/CeiValidator.php) e do [Bigai.Documentos.Brasil](https://github.com/marcos-cruz/Documento/blob/master/src/Bigai.Documentos.Brasil/Cei/Cei.cs), conferida contra os [dados abertos do Cadastro Nacional de Obras (CNO)](https://dados.gov.br/dados/conjuntos-dados/cadastro-nacional-de-obras-cno) da Receita Federal. +Verifica se um número de CEI (Cadastro Específico do INSS) é válido. O CEI identifica o empregador sem CNPJ, como uma obra ou um produtor rural: 12 dígitos impressos como `00.000.00000/00`, sendo o último um dígito verificador calculado sobre os 11 dígitos da base com os pesos 7, 4, 1, 8, 5, 2, 1, 6, 3, 7 e 4. Aceita os caracteres de máscara usuais e espaços entre e ao redor dos grupos, inclusive uma sequência deles entre dois grupos. A Receita Federal não publica essa regra de dígito verificador, então ela segue as implementações de referência do [yii2-br-validator](https://github.com/yiibr/yii2-br-validator/blob/master/src/CeiValidator.php) e do [Bigai.Documentos.Brasil](https://github.com/marcos-cruz/Documento/blob/master/src/Bigai.Documentos.Brasil/Cei/Cei.cs), conferida contra os [dados abertos do Cadastro Nacional de Obras (CNO)](https://dados.gov.br/dados/conjuntos-dados/cadastro-nacional-de-obras-cno) da Receita Federal. ```javascript import { isValidCei } from '@brazilian-utils/brazilian-utils'; @@ -1622,7 +1826,7 @@ isValidCei('000000000000'); // false (dígitos repetidos) ## formatCei -Formata um número de CEI (Cadastro Específico do INSS) na máscara usual `00.000.00000/00`, a mesma em que as implementações de referência do dígito verificador concordam (a Receita Federal não a publica). Formata progressivamente, até onde os dígitos informados alcançarem, então também pode ser usada como máscara de digitação. `options.pad` (parte de `FormatCeiOptions`) preenche a esquerda com zeros até 12 dígitos. +Formata um número de CEI (Cadastro Específico do INSS) na máscara usual `00.000.00000/00`, a mesma em que as implementações de referência do dígito verificador concordam (a Receita Federal não a publica). Formata progressivamente, até onde os dígitos informados alcançarem, então também pode ser usada como máscara de digitação. `options.pad` (parte de `FormatCeiOptions`) preenche à esquerda com zeros até 12 dígitos (padrão `false`). ```javascript import { formatCei } from '@brazilian-utils/brazilian-utils'; @@ -1632,9 +1836,19 @@ formatCei(249859674386); // 24.985.96743/86 formatCei('249', { pad: true }); // 00.000.00002/49 ``` +## parseCei + +Remove a formatação do CEI (Cadastro Específico do INSS), mantém apenas os dígitos e limita o resultado a 12 dígitos. Um valor parcial passa adiante até onde vai; use `isValidCei` para verificar o número em si. + +```javascript +import { parseCei } from '@brazilian-utils/brazilian-utils'; + +parseCei('27.729.71181/87'); // '277297118187' +``` + ## isValidCno -Verifica se um número de CNO (Cadastro Nacional de Obras) é válido. O CNO substituiu o CEI para obras e manteve a mesma numeração, então uma obra registrada sob um CEI antigo conserva o número e os dois cadastros são validados do mesmo jeito: 12 dígitos impressos como `00.000.00000/00`, com o dígito verificador calculado sobre os 11 dígitos da base. A Receita Federal não publica a regra do dígito verificador; ela foi confirmada contra os [dados abertos do Cadastro Nacional de Obras (CNO)](https://dados.gov.br/dados/conjuntos-dados/cadastro-nacional-de-obras-cno) da Receita Federal: todas as 38432 obras registradas em Minas Gerais passam nesta verificação. +Verifica se um número de CNO (Cadastro Nacional de Obras) é válido. O CNO substituiu o CEI para obras e manteve a mesma numeração, então uma obra registrada sob um CEI antigo conserva o número e os dois cadastros são validados do mesmo jeito: 12 dígitos impressos como `00.000.00000/00`, com o dígito verificador calculado sobre os 11 dígitos da base. A Receita Federal não publica a regra do dígito verificador; ela foi confirmada contra os [dados abertos do Cadastro Nacional de Obras (CNO)](https://dados.gov.br/dados/conjuntos-dados/cadastro-nacional-de-obras-cno) da Receita Federal: todas as obras do recorte de Minas Gerais desse conjunto passam nesta verificação. A página do catálogo publica apenas a descrição e os links de download do conjunto, não esse resultado. ```javascript import { isValidCno } from '@brazilian-utils/brazilian-utils'; @@ -1648,7 +1862,7 @@ isValidCno('000000000000'); // false (dígitos repetidos) ## formatCno -Formata um número de CNO (Cadastro Nacional de Obras). O CNO manteve a numeração do CEI, então os dois compartilham a mesma máscara de 12 dígitos, `00.000.00000/00`, a mesma em que as implementações de referência do dígito verificador concordam (a Receita Federal não a publica). Formata progressivamente, até onde os dígitos informados alcançarem, então também pode ser usada como máscara de digitação. `options.pad` (parte de `FormatCnoOptions`) preenche a esquerda com zeros até 12 dígitos. +Formata um número de CNO (Cadastro Nacional de Obras). O CNO manteve a numeração do CEI, então os dois compartilham a mesma máscara de 12 dígitos, `00.000.00000/00`, a mesma em que as implementações de referência do dígito verificador concordam (a Receita Federal não a publica). Formata progressivamente, até onde os dígitos informados alcançarem, então também pode ser usada como máscara de digitação. `options.pad` (parte de `FormatCnoOptions`) preenche à esquerda com zeros até 12 dígitos (padrão `false`). ```javascript import { formatCno } from '@brazilian-utils/brazilian-utils'; @@ -1658,9 +1872,19 @@ formatCno(401800097960); // 40.180.00979/60 formatCno('979', { pad: true }); // 00.000.00009/79 ``` +## parseCno + +Remove a formatação do CNO (Cadastro Nacional de Obras), mantém apenas os dígitos e limita o resultado a 12 dígitos, a numeração que o CNO herdou do CEI. Um valor mais curto passa adiante até onde vai; use `isValidCno` para verificar o número em si. + +```javascript +import { parseCno } from '@brazilian-utils/brazilian-utils'; + +parseCno('11.113.01373/68'); // '111130137368' +``` + ## isValidCaepf -Verifica se um número de CAEPF (Cadastro de Atividade Econômica da Pessoa Física) é válido. O CAEPF substituiu o CEI para a pessoa física que contrata empregados: 14 dígitos impressos como `000.000.000/000-00`, formados pela base de 9 dígitos do CPF do titular, um número de ordem de 3 dígitos para os vários cadastros do mesmo titular e 2 dígitos verificadores. Os dois dígitos usam o módulo 11 do CNPJ e o par resultante é somado a 12, com retorno a zero acima de 99. A Receita Federal não publica o layout nem a regra dos dígitos verificadores: os dois estão descritos em [ghiorzi.org](http://ghiorzi.org/DVnew.htm) e são implementados do mesmo jeito pelo [brazilian-values](https://github.com/VitorLuizC/brazilian-values/blob/master/src/validators/isCAEPF.ts). +Verifica se um número de CAEPF (Cadastro de Atividade Econômica da Pessoa Física) é válido. O CAEPF substituiu o CEI para a pessoa física que contrata empregados: 14 dígitos impressos como `000.000.000/000-00`, formados pela base de 9 dígitos do CPF do titular, um número de ordem de 3 dígitos para os vários cadastros do mesmo titular e 2 dígitos verificadores. Os dois dígitos verificadores são o módulo 11 do CNPJ na formulação da referência citada: os pesos vão de 9 até 2 da direita para a esquerda e o dígito é o próprio resto, com o resto 10 lido como 0 — o mesmo dígito que os pesos de 2 a 9 do CNPJ com `11 - resto` produzem. O par resultante é somado a 12, com retorno a zero acima de 99. Uma base cujos 12 dígitos são todos iguais é rejeitada antes do cálculo dos dígitos verificadores, do mesmo jeito que `isValidCei` e `isValidCno` rejeitam um número de CEI/CNO repetido, então o `00000000000012`, que de resto é bem formado, é inválido. A Receita Federal não publica o layout nem a regra dos dígitos verificadores: os dois estão descritos em [ghiorzi.org](http://ghiorzi.org/DVnew.htm) e são implementados do mesmo jeito pelo [brazilian-values](https://github.com/VitorLuizC/brazilian-values/blob/master/src/validators/isCAEPF.ts). ```javascript import { isValidCaepf } from '@brazilian-utils/brazilian-utils'; @@ -1669,12 +1893,13 @@ isValidCaepf('293.118.610/001-84'); // true isValidCaepf('41142260000101'); // true isValidCaepf(29311861000184); // true isValidCaepf('29311861000185'); // false (dígitos verificadores inválidos) -isValidCaepf('00000000000000'); // false (dígitos repetidos) +isValidCaepf('00000000000000'); // false (dígitos da base repetidos) +isValidCaepf('00000000000012'); // false (dígitos da base repetidos) ``` ## formatCaepf -Formata um número de CAEPF (Cadastro de Atividade Econômica da Pessoa Física) na máscara usual `000.000.000/000-00`, a mesma em que as fontes da regra do dígito verificador concordam (a Receita Federal não a publica). Formata progressivamente, até onde os dígitos informados alcançarem, então também pode ser usada como máscara de digitação. `options.pad` (parte de `FormatCaepfOptions`) preenche a esquerda com zeros até 14 dígitos. +Formata um número de CAEPF (Cadastro de Atividade Econômica da Pessoa Física) na máscara usual `000.000.000/000-00`, a mesma em que as fontes da regra do dígito verificador concordam (a Receita Federal não a publica). Formata progressivamente, até onde os dígitos informados alcançarem, então também pode ser usada como máscara de digitação. `options.pad` (parte de `FormatCaepfOptions`) preenche à esquerda com zeros até 14 dígitos (padrão `false`). ```javascript import { formatCaepf } from '@brazilian-utils/brazilian-utils'; @@ -1684,22 +1909,34 @@ formatCaepf(41142260000101); // 411.422.600/001-01 formatCaepf('184', { pad: true }); // 000.000.000/001-84 ``` +## parseCaepf + +Remove a formatação do CAEPF (Cadastro de Atividade Econômica da Pessoa Física), mantém apenas os dígitos e limita o resultado a 14 dígitos. Um valor mais curto passa adiante até onde vai; use `isValidCaepf` para verificar o número em si. + +```javascript +import { parseCaepf } from '@brazilian-utils/brazilian-utils'; + +parseCaepf('293.118.610/001-84'); // '29311861000184' +``` + ## isValidRegistroProfissional -Verifica a estrutura de um número de registro/inscrição profissional. As opções são tipadas como `IsValidRegistroProfissionalOptions`: `options.council` escolhe o conselho emissor (`"OAB"`, `"CRM"`, `"CRO"`, `"CRP"` ou `"CRC"`) e o `options.stateCode` opcional verifica a UF embutida (ignorado para `"CRP"`, cujo prefixo de 2 dígitos é um código regional, não uma UF literal). É apenas uma verificação estrutural: a quantidade de dígitos e a UF são validadas, mas nenhum dígito verificador é calculado, mesmo para o CRC, cujo formato inclui um. Um registro no CRC é a UF, 6 dígitos e o tipo de registro (`"O"` Originário, `"P"` Provisório ou `"T"` Transferido, que nada diz sobre a categoria profissional), conforme o [Manual de Registro do Sistema CFC/CRCs](https://cfc.org.br/wp-content/uploads/2018/04/1_manual_registro.pdf) (item 1.1) e a Resolução CFC nº 1.707/2023. O código regional do CRP precisa ser um dos [24 Conselhos Regionais](https://site.cfp.org.br/cfp/sistema-conselhos/conselhos-pelo-brasil/) do sistema CFP, de CRP-01 a CRP-24. A OAB, o CFM e o CFO não publicam o formato dos números que emitem, então as faixas de dígitos aceitas para `"OAB"`, `"CRM"` e `"CRO"` são convencionais, não normativas. O CREA não é suportado: seu formato de registro não pôde ser confirmado em uma fonte oficial e publicamente documentada após a unificação nacional de 2016 (RNP). +Verifica a estrutura de um número de registro/inscrição profissional. Recebe um único objeto, tipado como `IsValidRegistroProfissionalOptions`, no mesmo formato do `isValidBankAccount`: `value` é o número do registro, `council` escolhe o conselho emissor (`"OAB"`, `"CRM"`, `"CRO"`, `"CRP"` ou `"CRC"`) e o `stateCode` opcional verifica a UF embutida (ignorado para `"CRP"`, cujo prefixo de 2 dígitos é um código regional, não uma UF literal). Qualquer coisa que não seja um objeto, e um objeto sem `value` ou sem `council`, é `false`. Os formatos aceitos são de 4 a 6 dígitos mais a UF para `"OAB"` e `"CRM"`, de 3 a 6 dígitos mais a UF para `"CRO"`, um código regional de 2 dígitos mais 4 a 6 dígitos para `"CRP"`, e a UF mais 6 dígitos, o tipo de registro e um dígito verificador para `"CRC"`. É apenas uma verificação estrutural: a quantidade de dígitos e a UF são validadas, mas nenhum dígito verificador é calculado, mesmo para o CRC, cujo formato inclui um. Um registro no CRC é a UF, 6 dígitos, o tipo de registro (`"O"` Originário ou `"P"` Provisório, que nada diz sobre a categoria profissional) e o dígito verificador, conforme o [Manual de Registro do Sistema CFC/CRCs](https://cfc.org.br/wp-content/uploads/2018/04/1_manual_registro.pdf) (item 1.1). Um Registro Transferido ou Secundário acrescenta `"T"` ou `"S"` e a UF do CRC de destino **depois** do dígito verificador, conforme esse mesmo item e a [Resolução CFC nº 1.707/2023](https://www1.cfc.org.br/sisweb/SRE/docs/Res_1707.pdf), art. 5º parágrafo único: os exemplos do próprio Manual são `SP-123456/O-3 T-MG`, `TO-654321/P-8 T-SC` e `PI-111222/O-5 S-AC`. As duas UFs precisam ser códigos reais, e o `stateCode` é comparado com a de origem. O código regional do CRP precisa ser um dos [24 Conselhos Regionais](https://site.cfp.org.br/cfp/sistema-conselhos/conselhos-pelo-brasil/) do sistema CFP, de CRP-01 a CRP-24. Só o formato do CRC e esses códigos regionais do CRP se apoiam em fonte publicada: a página do CFP não publica o tamanho do número de inscrição, e a OAB, o CFM e o CFO não publicam formato algum, então as faixas de dígitos aceitas para `"CRP"`, `"OAB"`, `"CRM"` e `"CRO"` são convencionais, não normativas (a busca pública da OAB/SP tem `maxlength="7"`, e o CFM documenta CRMs com prefixo `300` e sufixo `P`, nenhum deles expresso por esses formatos). O CREA não é suportado: seu formato de registro não pôde ser confirmado em uma fonte oficial e publicamente documentada após a unificação nacional de 2016 (RNP). ```javascript import { isValidRegistroProfissional } from '@brazilian-utils/brazilian-utils'; -isValidRegistroProfissional('123456/SP', { council: 'OAB' }); // true -isValidRegistroProfissional('123456-RJ', { council: 'OAB', stateCode: 'SP' }); // false (UF divergente) -isValidRegistroProfissional('06/12345', { council: 'CRP' }); // true -isValidRegistroProfissional('SP-123456/O-3', { council: 'CRC' }); // true +isValidRegistroProfissional({ value: '123456/SP', council: 'OAB' }); // true +isValidRegistroProfissional({ value: '123456-RJ', council: 'OAB', stateCode: 'SP' }); // false (UF divergente) +isValidRegistroProfissional({ value: '06/12345', council: 'CRP' }); // true +isValidRegistroProfissional({ value: 'SP-123456/O-3', council: 'CRC' }); // true +isValidRegistroProfissional({ value: 'SP-123456/O-3 T-MG', council: 'CRC' }); // true (registro transferido) +isValidRegistroProfissional({ value: 'SP-123456/T-3', council: 'CRC' }); // false ("T" não é tipo de registro) ``` ## isValidVin -Valida se um VIN (Vehicle Identification Number / chassi) é válido. Verifica o tamanho (17 caracteres), as letras excluídas (`I`, `O`, `Q` nunca são válidas; estrutura da [ISO 3779:2009](https://www.iso.org/standard/52200.html)) e o dígito verificador na 9ª posição, calculado e transliterado conforme o [49 CFR 565.15](https://www.ecfr.gov/current/title-49/section-565.15). Esse dígito verificador é uma exigência norte-americana (49 CFR 565.15 / SAE J853): a Resolução CONTRAN nº 24/1998 e a ABNT NBR 6066 definem a estrutura do VIN brasileiro, mas não o exigem, então muitos VINs fabricados no Brasil não possuem um dígito verificador correspondente. Esta função é, portanto, uma verificação estrutural no padrão norte-americano, não um validador universal de VINs brasileiros. Não diferencia maiúsculas de minúsculas e remove espaços nas extremidades. +Valida se um VIN (Vehicle Identification Number / chassi) é válido. Verifica o tamanho (17 caracteres), as letras excluídas (`I`, `O`, `Q` nunca são válidas; estrutura da [ISO 3779:2009](https://www.iso.org/standard/52200.html)) e o dígito verificador na 9ª posição, calculado e transliterado conforme o [49 CFR 565.15](https://www.ecfr.gov/current/title-49/section-565.15). Esse dígito verificador é uma exigência norte-americana (49 CFR 565.15 / SAE J853): a [Resolução CONTRAN nº 968/2022](https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes/resolucao9682022.pdf) (que revogou a Resolução CONTRAN nº 24/1998 a partir de 1º de janeiro de 2025) e a ABNT NBR 6066 definem a estrutura do VIN brasileiro, mas não o exigem, então muitos VINs fabricados no Brasil não possuem um dígito verificador correspondente. Esta função é, portanto, uma verificação estrutural no padrão norte-americano, não um validador universal de VINs brasileiros. Não diferencia maiúsculas de minúsculas e remove espaços nas extremidades. Um VIN é impresso como uma sequência única de 17 caracteres, então, diferente dos documentos que este pacote mascara (`isValidCpf`, `isValidCnpj`, `isValidNfeKey`), ele não tem limite de grupo onde escrever um separador e nenhum é aceito: um espaço, `.`, `-` ou `/` entre os caracteres é rejeitado em vez de removido. Um valor cujos 17 caracteres são todos iguais (`'00000000000000000'`) é rejeitado mesmo com o dígito verificador correspondente, do jeito que todo outro validador deste pacote rejeita um documento de dígitos repetidos. ```javascript import { isValidVin } from '@brazilian-utils/brazilian-utils'; @@ -1707,12 +1944,13 @@ import { isValidVin } from '@brazilian-utils/brazilian-utils'; isValidVin('1HGCM82633A004352'); // true isValidVin('1m8gdm9axkp042788'); // true (dígito verificador X, minúsculo) isValidVin('1HGCM82633A004353'); // false (dígito verificador inválido) +isValidVin('00000000000000000'); // false (todos os caracteres iguais, ainda que o dígito feche) isValidVin('1HGCM8263IA004352'); // false (contém a letra excluída I) ``` ## isValidCbo -Valida se um código CBO (Classificação Brasileira de Ocupações) existe na tabela de ocupações do MTE. Aceita o código com ou sem a máscara de hífen, ou como número. Uma string só é lida como código quando está escrita em uma dessas formas (os 6 dígitos, ou a máscara `NNNN-NN`, com os separadores usuais entre os grupos e espaços em branco opcionais no início e no fim), e um número só quando é um inteiro seguro não negativo. +Valida se um código CBO (Classificação Brasileira de Ocupações) existe na tabela de ocupações do MTE. Aceita o código com ou sem a máscara de hífen, ou como número. Uma string só é lida como código quando está escrita em uma dessas formas (os 6 dígitos, ou a máscara `NNNN-NN`, com um único separador entre os grupos e espaços em branco opcionais no início e no fim), e um número só quando é um inteiro seguro não negativo. Um código CBO sempre tem 6 dígitos e os zeros à esquerda fazem parte dele, então um valor escrito apenas com dígitos é completado com zeros à esquerda até 6, seja ele string ou número, exatamente como `getBankByCode` completa um código de banco: `10205`, `'10205'` e `'010205'` são o mesmo código. Um valor mascarado já carrega os seus separadores e é lido como foi escrito. ```javascript import { isValidCbo } from '@brazilian-utils/brazilian-utils'; @@ -1720,36 +1958,52 @@ import { isValidCbo } from '@brazilian-utils/brazilian-utils'; isValidCbo('2124-05'); // true isValidCbo('212405'); // true isValidCbo(212405); // true +isValidCbo(10205); // true (completado para 6 dígitos, ou seja, '010205') +isValidCbo('10205'); // true (completado do mesmo jeito que um número) isValidCbo('000000'); // false isValidCbo('2124abc05'); // false (não é uma forma documentada) isValidCbo(-212405); // false (não é um inteiro seguro não negativo) ``` -Os títulos das ocupações vêm das [tabelas oficiais da CBO 2002 publicadas pelo MTE](http://www.mtecbo.gov.br/cbosite/pages/downloads.jsf). +Os títulos das ocupações vêm da [tabela oficial de ocupações da CBO 2002 publicada pelo MTE](https://www.gov.br/trabalho-e-emprego/pt-br/assuntos/cbo/servicos/downloads/cbo2002-ocupacao.csv). + +## parseCbo + +Remove a formatação do CBO (Classificação Brasileira de Ocupações), mantém apenas os dígitos e limita o resultado a 6 dígitos. Um valor mais curto passa adiante até onde vai e nada é preenchido com zeros à esquerda aqui, então o zero inicial de um código como `010205` precisa ser escrito; use `getCbo` ou `isValidCbo`, que preenchem um código numérico sem máscara, para consultar uma ocupação. + +```javascript +import { parseCbo } from '@brazilian-utils/brazilian-utils'; + +parseCbo('2124-05'); // '212405' +``` ## getCbo -Consulta um código CBO (Classificação Brasileira de Ocupações) e retorna o título oficial da ocupação. Um `number` mantém os zeros à esquerda implícitos: `getCbo(10205)` é lido como `010205`. Valem as mesmas regras de entrada de `isValidCbo`: uma string precisa estar escrita com os 6 dígitos ou com a máscara `NNNN-NN`, e um número precisa ser um inteiro seguro não negativo. +Consulta um código CBO (Classificação Brasileira de Ocupações) e retorna o título oficial da ocupação, no registro `{ code, description }` que toda consulta desta biblioteca devolve. Um valor escrito apenas com dígitos mantém os zeros à esquerda implícitos, tanto como string quanto como número: `getCbo(10205)` e `getCbo('10205')` são lidos como `010205`. Valem as mesmas regras de entrada de `isValidCbo`: uma string precisa estar escrita com os 6 dígitos ou com a máscara `NNNN-NN`, e um número precisa ser um inteiro seguro não negativo. ```javascript import { getCbo } from '@brazilian-utils/brazilian-utils'; -getCbo('2124-05'); // { code: '212405', title: 'Analista de desenvolvimento de sistemas' } +getCbo('2124-05'); // { code: '212405', description: 'Analista de desenvolvimento de sistemas' } +getCbo(10205); // { code: '010205', description: 'Oficial da aeronáutica' } (completado para 6 dígitos) +getCbo('10205'); // { code: '010205', description: 'Oficial da aeronáutica' } (completado do mesmo jeito) getCbo('000000'); // null getCbo('2124abc05'); // null (não é uma forma documentada) ``` -Os títulos das ocupações vêm das [tabelas oficiais da CBO 2002 publicadas pelo MTE](http://www.mtecbo.gov.br/cbosite/pages/downloads.jsf). +Os títulos das ocupações vêm da [tabela oficial de ocupações da CBO 2002 publicada pelo MTE](https://www.gov.br/trabalho-e-emprego/pt-br/assuntos/cbo/servicos/downloads/cbo2002-ocupacao.csv). ## isValidCnae -Valida se um código de subclasse CNAE (Classificação Nacional de Atividades Econômicas) existe na tabela CNAE 2.3 publicada pelo IBGE. Aceita o código com ou sem a máscara `NNNN-N/NN`, ou como número. Uma string só é lida como código quando está escrita em uma dessas formas (os 7 dígitos, ou a máscara, com os separadores usuais entre os grupos e espaços em branco opcionais no início e no fim), e um número só quando é um inteiro seguro não negativo. +Valida se um código de subclasse CNAE (Classificação Nacional de Atividades Econômicas) existe na [tabela CNAE-Subclasses 2.3 publicada pelo IBGE](https://concla.ibge.gov.br/busca-online-cnae.html), a revisão de subclasses atual da CNAE 2.0. Aceita o código com ou sem a máscara `NNNN-N/NN`, ou como número. Uma string só é lida como código quando está escrita em uma dessas formas (os 7 dígitos, ou a máscara, com um único separador entre os grupos e espaços em branco opcionais no início e no fim), e um número só quando é um inteiro seguro não negativo. Um código de subclasse CNAE sempre tem 7 dígitos e os zeros à esquerda fazem parte dele, então um valor escrito apenas com dígitos é completado com zeros à esquerda até 7, seja ele string ou número: `111301`, `'111301'` e `'0111301'` são o mesmo código. Um valor mascarado já carrega os seus separadores e é lido como foi escrito. ```javascript import { isValidCnae } from '@brazilian-utils/brazilian-utils'; isValidCnae('6201-5/01'); // true isValidCnae('6201501'); // true +isValidCnae(111301); // true (completado para 7 dígitos, ou seja, '0111301') +isValidCnae('111301'); // true (completado do mesmo jeito que um número) isValidCnae('0000000'); // false isValidCnae('0111abc301'); // false (não é uma forma documentada) isValidCnae(-111301); // false (não é um inteiro seguro não negativo) @@ -1757,70 +2011,128 @@ isValidCnae(-111301); // false (não é um inteiro seguro não negativo) ## formatCnae -Formata um código de subclasse CNAE (Classificação Nacional de Atividades Econômicas). +Formata um código de subclasse CNAE (Classificação Nacional de Atividades Econômicas). `options.pad` (parte de `FormatCnaeOptions`) funciona exatamente como em `formatCpf`/`formatCep`: com o padrão `false` a máscara é aplicada progressivamente, até onde o valor vai, que é o que um campo sendo digitado precisa; com `true` o valor é primeiro completado com zeros à esquerda até os 7 dígitos de uma subclasse completa, então ele sempre volta com a máscara inteira. Um número é tratado exatamente como a string dos seus dígitos, ou seja, só é completado com `pad: true`. Como todo formatador deste pacote, o valor é lido pelos seus dígitos e a máscara é aplicada até onde eles vão: caracteres fora da máscara são descartados e um número é lido como a string dos seus dígitos, sinal e ponto decimal inclusos. Use `isValidCnae` para verificar um código. ```javascript import { formatCnae } from '@brazilian-utils/brazilian-utils'; formatCnae('6201501'); // 6201-5/01 +formatCnae('62'); // 62 (máscara aplicada até onde o valor vai) +formatCnae('62015'); // 6201-5 +formatCnae('62', { pad: true }); // 0000-0/62 (completado até 7 dígitos antes) +formatCnae(111301, { pad: true }); // 0111-3/01 +formatCnae('abc6201501'); // 6201-5/01 (só os dígitos são lidos) +formatCnae(-6201501); // 6201-5/01 +``` + +## parseCnae + +Remove a formatação do CNAE (Classificação Nacional de Atividades Econômicas), mantém apenas os dígitos e limita o resultado aos 7 dígitos de um código de subclasse completo. Nada é preenchido com zeros à esquerda aqui; use `getCnae` ou `isValidCnae`, que preenchem um código numérico sem máscara, para consultar uma subclasse. + +```javascript +import { parseCnae } from '@brazilian-utils/brazilian-utils'; + +parseCnae('6201-5/01'); // '6201501' +parseCnae('62'); // '62' (a partial code is kept as written) ``` ## getCnae -Busca um código de subclasse CNAE (Classificação Nacional de Atividades Econômicas) e retorna seu código formatado e a descrição oficial. Um `number` mantém os zeros à esquerda implícitos: `getCnae(111301)` é lido como `0111301`. Valem as mesmas regras de entrada de `isValidCnae`: uma string precisa estar escrita com os 7 dígitos ou com a máscara `NNNN-N/NN`, e um número precisa ser um inteiro seguro não negativo. +Busca um código de subclasse CNAE (Classificação Nacional de Atividades Econômicas) e retorna seu código e a descrição oficial. O `code` volta com os 7 dígitos crus, como em toda consulta desta biblioteca; passe-o para `formatCnae` para obter a forma `NNNN-N/NN`. Um valor escrito apenas com dígitos mantém os zeros à esquerda implícitos, tanto como string quanto como número: `getCnae(111301)` e `getCnae('111301')` são lidos como `0111301`. Valem as mesmas regras de entrada de `isValidCnae`: uma string precisa estar escrita com os 7 dígitos ou com a máscara `NNNN-N/NN`, e um número precisa ser um inteiro seguro não negativo. ```javascript -import { getCnae } from '@brazilian-utils/brazilian-utils'; +import { formatCnae, getCnae } from '@brazilian-utils/brazilian-utils'; -getCnae('6201501'); // { code: '6201-5/01', description: 'DESENVOLVIMENTO DE PROGRAMAS DE COMPUTADOR SOB ENCOMENDA' } +getCnae('6201-5/01'); // { code: '6201501', description: 'DESENVOLVIMENTO DE PROGRAMAS DE COMPUTADOR SOB ENCOMENDA' } +getCnae(111301); // { code: '0111301', description: 'CULTIVO DE ARROZ' } (completado para 7 dígitos) +getCnae('111301'); // { code: '0111301', description: 'CULTIVO DE ARROZ' } (completado do mesmo jeito) getCnae('0000000'); // null getCnae('0111abc301'); // null (não é uma forma documentada) +formatCnae(getCnae('6201501')?.code); // 6201-5/01 (aplicar a máscara é trabalho do formatador) ``` ## isValidNcm -Valida se um código NCM (Nomenclatura Comum do Mercosul) existe na tabela vigente publicada pelo Siscomex/MDIC. Aceita o código com ou sem a máscara de pontos, ou como número. +Valida se um código NCM (Nomenclatura Comum do Mercosul) existe na tabela vigente publicada pelo Siscomex/MDIC. Aceita o código com ou sem a máscara de pontos, ou como número. Uma string só é lida como código quando está escrita em uma dessas formas (os 8 dígitos, ou a máscara `NNNN.NN.NN`, com um único separador entre os grupos e espaços em branco opcionais no início e no fim), e um número só quando é um inteiro seguro não negativo. Um código NCM sempre tem 8 dígitos e os zeros à esquerda fazem parte dele, então um valor escrito apenas com dígitos é completado com zeros à esquerda até 8, seja ele string ou número: `1012100`, `'1012100'` e `'01012100'` são o mesmo código. Um valor mascarado já carrega os seus separadores e é lido como foi escrito. ```javascript import { isValidNcm } from '@brazilian-utils/brazilian-utils'; isValidNcm('8471.30.12'); // true isValidNcm('84713012'); // true +isValidNcm(1012100); // true (completado para 8 dígitos, ou seja, '01012100') +isValidNcm('1012100'); // true (completado do mesmo jeito que um número) isValidNcm('00000000'); // false +isValidNcm('abc01012100'); // false (não é uma forma documentada) +isValidNcm(-84713012); // false (não é um inteiro seguro não negativo) ``` ## formatNcm -Formata um código NCM (Nomenclatura Comum do Mercosul). +Formata um código NCM (Nomenclatura Comum do Mercosul). `options.pad` (parte de `FormatNcmOptions`) funciona exatamente como em `formatCpf`/`formatCep`: com o padrão `false` a máscara é aplicada progressivamente, até onde o valor vai, que é o que um campo sendo digitado precisa; com `true` o valor é primeiro completado com zeros à esquerda até os 8 dígitos de um código completo, então ele sempre volta com a máscara inteira. Um número é tratado exatamente como a string dos seus dígitos, ou seja, só é completado com `pad: true`. Como todo formatador deste pacote, o valor é lido pelos seus dígitos e a máscara é aplicada até onde eles vão: caracteres fora da máscara são descartados e um número é lido como a string dos seus dígitos, sinal e ponto decimal inclusos. Use `isValidNcm` para verificar um código. ```javascript import { formatNcm } from '@brazilian-utils/brazilian-utils'; formatNcm('84713012'); // 8471.30.12 +formatNcm('8471'); // 8471 (máscara aplicada até onde o valor vai) +formatNcm('847130'); // 8471.30 +formatNcm('8471', { pad: true }); // 0000.84.71 (completado até 8 dígitos antes) +formatNcm('abc8471'); // 8471 (só os dígitos são lidos) +formatNcm(-84713012); // 8471.30.12 +``` + +## parseNcm + +Remove a formatação do NCM (Nomenclatura Comum do Mercosul), mantém apenas os dígitos e limita o resultado aos 8 dígitos de um código completo. Nada é preenchido com zeros à esquerda aqui; use `isValidNcm`, que preenche um código numérico sem máscara, para verificar um código na tabela oficial. + +```javascript +import { parseNcm } from '@brazilian-utils/brazilian-utils'; + +parseNcm('8471.30.12'); // '84713012' +parseNcm('8471'); // '8471' (a partial code is kept as written) ``` ## isValidCfop -Valida se um código CFOP (Código Fiscal de Operações e Prestações) existe na tabela oficial (Ajuste SINIEF 07/2001 e atualizações). Só os códigos operáveis contam: os títulos de grupo e subgrupo da nomenclatura oficial, os códigos terminados em `00` e `50` (1000, 1100, 1150, 5350, ...), são títulos de seção e não códigos que um documento pode carregar, então são rejeitados. +Valida se um código CFOP (Código Fiscal de Operações e Prestações) existe na tabela oficial. A tabela é o [Anexo II consolidado do Convênio SINIEF s/nº 1970](https://www.confaz.fazenda.gov.br/legislacao/ajustes/sinief/cfop_cvsn_1-6.24), o texto vigente (redação atual dada pelo Ajuste SINIEF 03/24, última alteração pelo [Ajuste SINIEF 39/25](https://www.confaz.fazenda.gov.br/legislacao/ajustes/2025/AJ039_25)), e não o texto congelado de 2001 do Ajuste SINIEF 07/01. Só os códigos operáveis contam: os títulos de grupo e subgrupo da nomenclatura oficial, os códigos terminados em `00` e `50` (1000, 1100, 1150, 5350, ...), são títulos de seção e não códigos que um documento pode carregar, então são rejeitados. + +Uma string só é lida como código quando está escrita em uma das formas documentadas (os 4 dígitos, ou a forma `N.NNN` impressa no anexo, com um único separador entre os grupos e espaços em branco opcionais no início e no fim), e um número só quando é um inteiro seguro não negativo. Nenhum código CFOP começa com zero, o seu primeiro dígito é o grupo da operação (1 a 7), então aqui nada é completado: um número e a string dos mesmos dígitos são lidos de forma idêntica. ```javascript import { isValidCfop } from '@brazilian-utils/brazilian-utils'; isValidCfop('5102'); // true +isValidCfop('1.101'); // true +isValidCfop('7504'); // true (incluído na reescrita de 2022 do anexo) isValidCfop('0000'); // false isValidCfop('1150'); // false (título de subgrupo, não é um código operável) +isValidCfop('abc5102'); // false (não é uma forma documentada) +isValidCfop(-5102); // false (não é um inteiro seguro não negativo) +``` + +## parseCfop + +Remove a formatação do CFOP (Código Fiscal de Operações e Prestações), mantém apenas os dígitos e limita o resultado a 4 dígitos. Um valor mais curto passa adiante até onde vai. Nenhum código CFOP começa com zero, o primeiro dígito é o grupo da operação, de 1 a 7, então nada é preenchido com zeros aqui. + +```javascript +import { parseCfop } from '@brazilian-utils/brazilian-utils'; + +parseCfop('5.102'); // '5102' ``` ## getCfop -Busca um código CFOP (Código Fiscal de Operações e Prestações) e retorna seu código e a descrição oficial. Os títulos de grupo e subgrupo da nomenclatura oficial, os códigos terminados em `00` e `50`, não estão na tabela e retornam `null`. +Busca um código CFOP (Código Fiscal de Operações e Prestações) e retorna seu código e a descrição oficial, na redação do [Anexo II consolidado do Convênio SINIEF s/nº 1970](https://www.confaz.fazenda.gov.br/legislacao/ajustes/sinief/cfop_cvsn_1-6.24), no texto vigente, com última alteração pelo [Ajuste SINIEF 39/25](https://www.confaz.fazenda.gov.br/legislacao/ajustes/2025/AJ039_25). Os títulos de grupo e subgrupo da nomenclatura oficial, os códigos terminados em `00` e `50`, não estão na tabela e retornam `null`. Valem as mesmas regras de entrada de `isValidCfop`. ```javascript import { getCfop } from '@brazilian-utils/brazilian-utils'; -getCfop('5102'); // { code: '5102', description: 'Venda de mercadoria adquirida ou recebida de terceiros' } +getCfop('1101'); // { code: '1101', description: 'Compra para industrialização ou produção rural' } +getCfop('7504'); // { code: '7504', description: 'Exportação de mercadoria que foi objeto de formação de lote de exportação' } getCfop('0000'); // null getCfop('5350'); // null (título de subgrupo, não é um código operável) +getCfop('abc5102'); // null (não é uma forma documentada) ``` ## isValidCst @@ -1829,33 +2141,49 @@ Valida um código de CST (Código de Situação Tributária) para um tributo. In | Tributo | Formato | Códigos aceitos | | --- | --- | --- | -| `icms` | 3 dígitos (origem + CST) | origem `0`-`8` + um de `00`, `10`, `20`, `30`, `40`, `41`, `50`, `51`, `60`, `70`, `90` | +| `icms` | 3 dígitos (origem + CST) | origem `0`-`8` + um de `00`, `02`, `10`, `15`, `20`, `30`, `40`, `41`, `50`, `51`, `53`, `60`, `61`, `70`, `90` | | `ipi` | 2 dígitos | `00`, `01`, `02`, `03`, `04`, `05`, `49`, `50`, `51`, `52`, `53`, `54`, `55`, `99` | | `pis` | 2 dígitos | `01`-`09`, `49`, `50`-`56`, `60`-`67`, `70`-`75`, `98`, `99` | | `cofins` | 2 dígitos | mesma tabela do `pis` | -`options.tax` (parte de `IsValidCstOptions`) é opcional: omita-o para aceitar um código que exista em qualquer uma das quatro tabelas acima. +`options.tax` (parte de `IsValidCstOptions`) é opcional: omita-o para aceitar um código que exista em qualquer uma das quatro tabelas acima. Um `tax` fora desses quatro valores cai nesse mesmo padrão em tempo de execução, do jeito que toda outra opção escalar desta biblioteca trata um valor que não conhece. + +A Tabela B do ICMS é a vigente: o [Anexo I consolidado do Convênio SINIEF s/nº 1970](https://www.confaz.fazenda.gov.br/legislacao/ajustes/sinief/cvsn_70), cuja redação atual veio do [Ajuste SINIEF 39/23](https://www.confaz.fazenda.gov.br/legislacao/ajustes/2023/ajuste-sinief-39-23) (efeitos a partir de 01.12.23) e que o [Ajuste SINIEF 20/24](https://www.confaz.fazenda.gov.br/legislacao/ajustes/2024/AJ020_24) alterou suprimindo os itens 12, 13, 52, 72 e 74 (efeitos a partir de 09.07.24) antes que eles chegassem a produzir efeitos: o 39/23 havia adiado a produção de efeitos deles para 1º de outubro de 2024, então a revogação os alcançou antes e esses códigos nunca estiveram em vigor. `02`, `15`, `53` e `61` são seus códigos de monofasia de combustíveis. + +Uma string só é lida como código quando está escrita em uma das formas documentadas (os 2 dígitos de um código da Tabela B, ou os 3 dígitos da forma do ICMS com um único separador opcional depois do dígito de origem, além de espaços em branco opcionais no início e no fim), e um número só quando é um inteiro seguro não negativo. O dígito de origem é a única fronteira que um CST impresso tem, então `'0 10'` e `'1-10'` são lidos, mas `'0-0'`, `'11-0'` e `'00-'` não. + +Um único dígito é mais estreito que qualquer uma das formas documentadas, então ele é completado com zeros à esquerda até os 3 dígitos da forma do ICMS, seja ele string ou número: `0`, `'0'` e `'000'` são todos o código ICMS `000`. Um valor de 2 dígitos já é uma forma documentada, um código da Tabela B, e é lido como foi escrito, ou seja, um código da Tabela B mantém os seus dois dígitos: `'07'`, não `7`, que é o código ICMS `007`. ```javascript import { isValidCst } from '@brazilian-utils/brazilian-utils'; isValidCst('000', { tax: 'icms' }); // true +isValidCst(0, { tax: 'icms' }); // true (um único dígito é completado até a forma de 3 dígitos, '000') +isValidCst('0', { tax: 'icms' }); // true (completado do mesmo jeito que um número) isValidCst('110', { tax: 'icms' }); // true +isValidCst('002', { tax: 'icms' }); // true (monofasia de combustíveis) isValidCst('06', { tax: 'pis' }); // true isValidCst('99', { tax: 'ipi' }); // true isValidCst('110'); // true (encontrado na tabela icms, tax omitido) +isValidCst('000', { tax: 'nope' }); // true (um tax desconhecido cai em todas as tabelas) isValidCst('999'); // false (não existe em nenhuma tabela) +isValidCst('abc110'); // false (não é uma forma documentada) +isValidCst(-110); // false (não é um inteiro seguro não negativo) ``` ## isValidCsosn -Valida se um código de CSOSN (Código de Situação da Operação no Simples Nacional) é um dos 10 códigos definidos pelo Ajuste SINIEF 03/2010: `101`, `102`, `103`, `201`, `202`, `203`, `300`, `400`, `500` ou `900`. +Valida se um código de CSOSN (Código de Situação da Operação no Simples Nacional) é um dos 10 códigos do [Anexo III-A consolidado do Convênio SINIEF s/nº 1970](https://www.confaz.fazenda.gov.br/legislacao/ajustes/sinief/cvsn_70), a tabela instituída pelo Ajuste SINIEF 03/2010: `101`, `102`, `103`, `201`, `202`, `203`, `300`, `400`, `500` ou `900`. + +Uma string só é lida como código quando está escrita como os 3 dígitos puros, com espaços em branco opcionais no início e no fim: um CSOSN não tem agrupamento impresso (a NF-e leva o dígito de origem no seu próprio campo `orig`), então `'1-01'` é rejeitado; um número só é lido quando é um inteiro seguro não negativo. Nenhum código CSOSN começa com zero, a tabela vai de `101` a `900`, então aqui nada é completado: um número e a string dos mesmos dígitos são lidos de forma idêntica. ```javascript import { isValidCsosn } from '@brazilian-utils/brazilian-utils'; isValidCsosn('101'); // true isValidCsosn('999'); // false +isValidCsosn('abc101'); // false (não é uma forma documentada) +isValidCsosn(-101); // false (não é um inteiro seguro não negativo) ``` ## removeAccents diff --git a/docs/utilities.md b/docs/utilities.md index 44d56ce7..5a496e51 100644 --- a/docs/utilities.md +++ b/docs/utilities.md @@ -2,7 +2,7 @@ Here you will find all the utilities available for use. -> **Input handling:** no synchronous public function throws on `null`/`undefined` or a wrong-type value; the two network helpers, `getAddressInfoByCep` and `getCepInfoByAddress`, reject with their typed errors (see their sections). `isValid*` predicates return `false`; `isHoliday` returns `false`; `getHolidays` returns `[]`; `generateProcessoJuridico` returns `null`; `getMunicipality` returns `null` for a malformed/unmatched lookup. Every other `format*`/`parse*` function (including `capitalize`) returns an empty value of its return type: `""` for strings, `0` for `parseCurrency`. `formatCurrency` returns `""` for a non-finite number. +> **Input handling:** no synchronous public function throws on `null`/`undefined` or a wrong-type value; the two network helpers, `getAddressInfoByCep` and `getCepInfoByAddress`, reject with their typed errors (see their sections). `isValid*` predicates return `false`; `isHoliday` returns `false`; `getHolidays` returns `[]`; `getBoletoInfo` returns `null` for an invalid boleto; `generateProcessoJuridico` returns `null`; `getMunicipality` returns `null` for a malformed/unmatched lookup. Every other `format*`/`parse*` function returns an empty value of its return type: every `format*` function, `capitalize`, and the string-returning `parse*` functions (`parseBoleto`, `parseCaepf`, `parseCbo`, `parseCei`, `parseCep`, `parseCertidao`, `parseCfop`, `parseCnae`, `parseCnh`, `parseCno`, `parseCnpj`, `parseCns`, `parseCpf`, `parseIban`, `parseLegalNature`, `parseLicensePlate`, `parseNcm`, `parseNfeKey`, `parsePassport`, `parsePhone`, `parsePis`, `parseProcessoJuridico`, `parseVoterId`) return `""`; `parseCurrency` returns `0`; the structured readers — `getCertidaoInfo`, `getIbanInfo`, `getNfeKeyInfo`, `getPixKeyInfo`, `getPixPayloadInfo` — return `null`. `formatCurrency` returns `""` for a non-finite number and for a value that cannot be coerced to one (a symbol, a plain object, a null-prototype object); `null`, arrays and booleans go through `Number()` as in 2.3.0. The one exception to the promise above: an object created with `Object.create(null)` has no `toString`, so the `format*`/`parse*` helpers that read their input as text still throw a `TypeError` for it, exactly as they did in 2.3.0. ## isValidCpf @@ -17,7 +17,7 @@ isValidCpf('111 444 777 35'); // true (whitespace mask) ## formatCpf -Format CPF. `options.obfuscate` (part of `FormatCpfOptions`) hides the first 3 digits and the 2 check digits (`***.456.789-**`), the gov.br / Receita Federal display convention, applied after `pad`. +Format CPF. `options.pad` (part of `FormatCpfOptions`) left-pads the value with zeros up to the 11 slots of the pattern before masking (default `false`). `options.obfuscate` (same type) hides the first 3 digits and the 2 check digits (`***.456.789-**`), the gov.br / Receita Federal display convention, applied after `pad`. It is read for truthiness, the way `pad` is, so any truthy value obfuscates. ```javascript import { formatCpf } from '@brazilian-utils/brazilian-utils'; @@ -39,7 +39,7 @@ parseCpf('746.506.880-00'); // 74650688000 ## generateCpf -Generate a valid random CPF. +Generate a valid random CPF. Uses `Math.random()` internally, so it is not cryptographically secure. ```javascript import { generateCpf } from '@brazilian-utils/brazilian-utils' @@ -50,7 +50,7 @@ generateCpf('SP'); // the 9th digit is 8, the SP região fiscal code ## isValidCnpj -Check if CNPJ is valid. Supports both the numeric format (`version: 1`, default) and the alphanumeric format (`version: 2`), and accepts the usual mask characters and whitespace. Options are typed as `IsValidCnpjOptions`. +Check if CNPJ is valid. `options.version` (part of `IsValidCnpjOptions`) picks which format is accepted: `1` (default) the numeric-only format, `2` both the numeric and the alphanumeric one; any other value is read as `1`, the way `formatCnpj` and `parseCnpj` read it. The usual mask characters and whitespace are accepted in either version. Version `2` has no reserved-value list, because the Receita Federal manual defines none for the alphanumeric format: a repeated-character alphanumeric base (all `A`s, say) that passes the checksum is accepted, while the numeric reserved numbers are rejected under version `1`. ```javascript import { isValidCnpj } from '@brazilian-utils/brazilian-utils'; @@ -61,7 +61,7 @@ isValidCnpj('q0slfmbd7vx439', { version: 2 }); // true (lowercase alphanumeric) ## formatCnpj -Format CNPJ. `options.obfuscate` (part of `FormatCnpjOptions`) hides the first 2 digits and the 2 check digits (`**.345.678/0001-**`), the gov.br / Receita Federal display convention. It applies to both versions and comes after `pad`. +Format CNPJ. `options.pad` (part of `FormatCnpjOptions`) left-pads the value with zeros up to the 14 slots of the pattern before masking (default `false`). `options.version` (same type) picks which CNPJ format to read: `1` (default) numeric only, `2` alphanumeric. `options.obfuscate` hides the first 2 digits and the 2 check digits (`**.345.678/0001-**`), the gov.br / Receita Federal display convention. It applies to both versions and comes after `pad`, and is read for truthiness, the way `pad` is, so any truthy value obfuscates. ```javascript import { formatCnpj } from '@brazilian-utils/brazilian-utils'; @@ -74,7 +74,7 @@ formatCnpj('12345678000195', { obfuscate: true }); // **.345.678/0001-** ## parseCnpj -Remove CNPJ formatting, return a normalized value, and cap the result to 14 characters. Options are typed as `ParseCnpjOptions`. +Remove CNPJ formatting, return a normalized value, and cap the result to 14 characters. `options.version` (part of `ParseCnpjOptions`) picks which CNPJ format to normalize: `1` (default) keeps digits only, `2` keeps letters and digits, so an alphanumeric CNPJ survives the round trip. ```javascript import { parseCnpj } from '@brazilian-utils/brazilian-utils'; @@ -85,7 +85,7 @@ parseCnpj('12.OUT.345/0001-99', { version: 2 }); // 12OUT345000199 ## isValidCep -Check if CEP ([brazilian postal code](https://en.wikipedia.org/wiki/C%C3%B3digo_de_Endere%C3%A7amento_Postal)) is valid. Accepts both `string` and `number` input; any spaces, dots and hyphens around/between the 8 digits are ignored, but any other character, a letter in particular, makes the value invalid. +Check if CEP ([brazilian postal code](https://en.wikipedia.org/wiki/C%C3%B3digo_de_Endere%C3%A7amento_Postal)) is valid. Accepts both `string` and `number` input, but a CEP that starts with `0` has to be passed as a string, since a number cannot keep the leading zero (`isValidCep(1310100)` is `false`, `isValidCep('01310100')` is `true`); any spaces, dots and hyphens around/between the 8 digits are ignored, but any other character, a letter in particular, makes the value invalid. ```javascript import { isValidCep } from '@brazilian-utils/brazilian-utils'; @@ -101,18 +101,20 @@ isValidCep('12345'); // false (invalid length) ## generateCnpj -Generate a valid random CNPJ. +Generate a valid random CNPJ. Uses `Math.random()` internally, so it is not cryptographically secure. The first argument is either the version, as before, or a `GenerateCnpjOptions` object with the same `version` plus `branch`, the "número de ordem" (filial) block in positions 9 to 12: an integer from 1 to 9999 written zero padded to four characters, random by default. An invalid `branch` is ignored and a random block is used, and the block stays numeric on the alphanumeric version. ```javascript import { generateCnpj } from '@brazilian-utils/brazilian-utils' generateCnpj(); generateCnpj(2); // alphanumeric CNPJ, e.g. 'Q0SLFMBD7VX439' +generateCnpj({ branch: 3 }); // ordem block '0003', e.g. '12345678000372' +generateCnpj({ version: 2, branch: 1 }); // alphanumeric CNPJ whose ordem block is '0001' ``` ## isValidBoleto -Check if boleto ([brazilian payment method](https://en.wikipedia.org/wiki/Boleto)) is valid. Supports both the 47 digit "cobrança bancária" boleto and the "boleto de arrecadação" (convênio/tributos): either its 48 digit linha digitável or its 44 digit barcode, both starting with `8`. +Check if boleto ([brazilian payment method](https://en.wikipedia.org/wiki/Boleto)) is valid. Supports both the 47 digit "cobrança bancária" boleto and the "boleto de arrecadação" (convênio/tributos): either its 48 digit linha digitável or its 44 digit barcode, both starting with `8`. One leniency is kept from 2.3.0: the código de moeda in position 4 of the cobrança bancária barcode is not checked, although Carta-Circular BCB nº 2.926/2000 fixes it at `9` (real), so a slip carrying any other moeda digit still validates. ```javascript import { isValidBoleto } from '@brazilian-utils/brazilian-utils'; @@ -123,7 +125,7 @@ isValidBoleto('846100000005246100291102005460339004695895061080'); // true (bole ## formatBoleto -Format a boleto number. The arrecadação (convênio/tributos) mask applies only to the 48 digit linha digitável starting with `8`; the 44 digit arrecadação barcode has no display grouping defined by FEBRABAN and keeps the "cobrança bancária" mask instead. +Format a boleto number. `options.pad` (part of `FormatBoletoOptions`) left-pads the value with zeros up to the number of slots in the pattern before masking (default `false`). The arrecadação (convênio/tributos) mask applies only to the 48 digit linha digitável starting with `8`; the 44 digit arrecadação barcode has no display grouping defined by FEBRABAN and keeps the "cobrança bancária" mask instead. ```javascript import { formatBoleto } from '@brazilian-utils/brazilian-utils'; @@ -146,7 +148,7 @@ parseBoleto('00190.00009 01149.718601 68524.522114 6 75860000102656'); // 001900 ## generateBoleto -Generate a valid random boleto. Pass `{ type: "arrecadacao" }` (typed as `GenerateBoletoOptions`) to generate a boleto de arrecadação instead of the default "bancario" (cobrança bancária) type. +Generate a valid random boleto. Pass `{ type: "arrecadacao" }` (typed as `GenerateBoletoOptions`) to generate a boleto de arrecadação instead of the default "bancario" (cobrança bancária) type. An arrecadação slip draws its segment from 1 to 7 (segment 9 is the banks' own) and its value identifier from all four values, `6` and `8` for an effective amount and `7` and `9` for a reference quantity, so both `hasEffectiveValue` branches of `getBoletoInfo` are reachable. ```javascript import { generateBoleto } from '@brazilian-utils/brazilian-utils'; @@ -157,7 +159,7 @@ generateBoleto({ type: 'arrecadacao' }); // "84610000000524610029110200546033900 ## getBoletoInfo -Extract information from a boleto (amount, expiration date, bank code). Accepts an optional `{ referenceDate }` (typed as `GetBoletoInfoOptions`) to resolve the "fator de vencimento" cycle as of a specific date instead of now (the factor's date-base cycle reset on 22/02/2025 per FEBRABAN). For a boleto de arrecadação, the result, typed as `BoletoInfo`, has no `bankCode`/`expirationDate` and instead carries `type: "arrecadacao"`, `segment`, `value` and `hasEffectiveValue`. +Extract information from a boleto (amount, expiration date, bank code). Returns `null` when `value` is not a valid boleto — `isValidBoleto` is checked first — so the result has to be narrowed before it is read. 2.3.0 returned `undefined` here; every getter of the package now answers an unresolved lookup with `null`, so only a strict `=== undefined` comparison is affected. Accepts an optional `{ referenceDate }` (typed as `GetBoletoInfoOptions`) to resolve the "fator de vencimento" cycle as of a specific date instead of now (the factor's date-base cycle reset on 22/02/2025 per FEBRABAN). Neither FEBRABAN nor the Banco Central publishes a way of telling an old cycle factor from a new cycle one, so every factor resolves to either of two dates 9000 days apart and `referenceDate` picks between them through the library's own safety windows: the same slip can resolve to the other candidate as time passes, so pass `referenceDate` explicitly whenever the answer has to stay stable. The cycle search never goes below the first cycle, so a `referenceDate` older than the scheme itself still resolves a factor to the oldest date that factor can denote rather than to one before the 07/10/1997 base date. For a boleto de arrecadação, the result, typed as `BoletoInfo`, still carries both keys but empty, `bankCode: ''` and `expirationDate: null`, since the slip has neither a bank code nor a fator de vencimento, and adds `type: "arrecadacao"`, `segment`, `value` and `hasEffectiveValue`. ```javascript import { getBoletoInfo } from '@brazilian-utils/brazilian-utils'; @@ -172,6 +174,8 @@ getBoletoInfo('00190000090114971860168524522114675860000102656', { getBoletoInfo('846100000005246100291102005460339004695895061080'); // { amount: 2461, expirationDate: null, bankCode: '', type: 'arrecadacao', segment: 4, value: 24.61, hasEffectiveValue: true } + +getBoletoInfo('invalid'); // null ``` ## isValidPixKey @@ -190,26 +194,26 @@ isValidPixKey('123.456.789-09', { accept: ['email', 'evp'] }); // false isValidPixKey('not a key'); // false ``` -## parsePixKey +## getPixKeyInfo -Identifies a Pix key and normalizes it to the canonical form the DICT expects inside a BR Code: 11 digit CPF, 14 character CNPJ, lowercased e-mail, E.164 mobile phone (a landline is not a Pix key) or lowercase UUID EVP. An 11 digit value that is valid both as a CPF and as a mobile phone is read as a CPF, unless it was written as a phone number (a `+55`/`0055` prefix or a DDD wrapped in parentheses). The CPF and the phone number are recognized by the way they are written, not only by the digits they carry, so surrounding text is not stripped away and `'abc123.456.789-09'` is not a CPF key. Returns `null` when the value is not a valid Pix key. The result is typed as `PixKey`. +Identifies a Pix key and normalizes it to the canonical form the DICT expects inside a BR Code: 11 digit CPF, 14 character CNPJ, lowercased e-mail, E.164 mobile phone (a landline is not a Pix key) or lowercase UUID EVP. An 11 digit value that is valid both as a CPF and as a mobile phone is read as a CPF, unless it was written as a phone number (a `+55`/`0055` prefix or a DDD wrapped in parentheses). The CPF and the phone number are recognized by the way they are written, not only by the digits they carry, so surrounding text is not stripped away and `'abc123.456.789-09'` is not a CPF key. An e-mail key is trimmed and lowercased, and one longer than the 77 characters the DICT allows is rejected. A value whose digits carry a valid CNPJ check digit is read as a CNPJ even when it starts with `0055`, since a phone key inside a BR Code always carries the `+55` prefix. Returns `null` when the value is not a valid Pix key. The result is typed as `PixKeyInfo`. ```javascript -import { parsePixKey } from '@brazilian-utils/brazilian-utils'; +import { getPixKeyInfo } from '@brazilian-utils/brazilian-utils'; -parsePixKey('123.456.789-09'); // { type: 'cpf', value: '12345678909' } -parsePixKey('Fulano@Example.COM '); // { type: 'email', value: 'fulano@example.com' } -parsePixKey('(11) 98765-4321'); // { type: 'phone', value: '+5511987654321' } -parsePixKey('71C7D9BE-4B85-4E43-9F1C-1F3B8B4E9A2D'); +getPixKeyInfo('123.456.789-09'); // { type: 'cpf', value: '12345678909' } +getPixKeyInfo('Fulano@Example.COM '); // { type: 'email', value: 'fulano@example.com' } +getPixKeyInfo('(11) 98765-4321'); // { type: 'phone', value: '+5511987654321' } +getPixKeyInfo('71C7D9BE-4B85-4E43-9F1C-1F3B8B4E9A2D'); // { type: 'evp', value: '71c7d9be-4b85-4e43-9f1c-1f3b8b4e9a2d' } -parsePixKey('(11) 3000-0000'); // null (a landline is not a Pix key) -parsePixKey('51998259765'); // { type: 'cpf', value: '51998259765' } (also a valid phone) -parsePixKey('+5551998259765'); // { type: 'phone', value: '+5551998259765' } +getPixKeyInfo('(11) 3000-0000'); // null (a landline is not a Pix key) +getPixKeyInfo('51998259765'); // { type: 'cpf', value: '51998259765' } (also a valid phone) +getPixKeyInfo('+5551998259765'); // { type: 'phone', value: '+5551998259765' } ``` ## isValidPixPayload -Check if a Pix BR Code payload (the string behind a Pix QR Code and behind "Pix copia e cola") is valid: well-formed TLV structure, the mandatory objects present, one of the "Merchant Account Information" templates carrying the `br.gov.bcb.pix` GUI with a key or a URL, a "Point of Initiation Method" object (`01`) that agrees with it (a key requires a static payload, so `01` is absent or `"11"`; a URL requires a dynamic one, so `01` is `"12"`), an amount (`54`) greater than zero in a static payload, and a matching CRC-16. The key itself is not checked against the DICT formats, use `isValidPixKey` for that. Payloads that carry the location in an Unreserved Template (IDs 80 to 99), as the "QR Code composto" of Pix Automático (Pix recorrente) does, are out of scope and reported as invalid. +Check if a Pix BR Code payload (the string behind a Pix QR Code and behind "Pix copia e cola") is valid: well-formed TLV structure, the mandatory objects present, one of the "Merchant Account Information" templates carrying the `br.gov.bcb.pix` GUI with a key or a URL, and a matching CRC-16. The "Point of Initiation Method" object (`01`) is advisory: the Manual do BR Code marks it optional and only assigns a meaning to the value `"12"` ("só pode ser utilizado uma vez"), so it may be absent from either shape and only a value outside `{"11", "12"}` makes the payload invalid. When a payload built around a key carries an amount (`54`), that amount must be greater than zero, unless the payload is a Pix Saque BR Code, i.e. unless it carries the ISPB of the "facilitador de serviço de saque" in sub-object 26-03 (`fss`) as §2.6 of the Pix manual prescribes; rejecting `"0"`/`"0.00"` without `fss` is a deliberate restriction of this library, not a rule of the manual. A `fss` written next to a PSP location makes the payload invalid: §2.7 of the Manual de Padrões para Iniciação do Pix maps the dynamic QR Code to exactly two sub-objects, `00` (GUI) and `25` (URL), and `fss` belongs to the static template of §2.6. The key itself is not checked against the DICT formats, use `isValidPixKey` for that. Unreserved Templates (IDs 80 to 99) are ignored: the "QR Code composto" of Pix Automático (Pix recorrente) writes its recurrence location in one of them, and when such a payload also carries a payment location in 26-25, as the composite example of the Pix manual does, it is accepted and read as an ordinary dynamic payload with the recurrence location dropped. Only a payload with no Pix template at all in IDs 26 to 51 is reported as invalid. ```javascript import { isValidPixPayload } from '@brazilian-utils/brazilian-utils'; @@ -222,29 +226,30 @@ isValidPixPayload( isValidPixPayload('00020126580014br.gov.bcb.pix...'); // false (broken CRC) ``` -## parsePixPayload +## getPixPayloadInfo -Parses a Pix BR Code payload into its fields. The payload is validated by `isValidPixPayload` first, so a malformed structure, a broken CRC or a missing mandatory object returns `null` instead of a partial result. A static payload comes back with `key`, a dynamic one with `url`. The result is typed as `PixPayload`; `pointOfInitiation` is typed as `PixPointOfInitiation` (`"static"` or `"dynamic"`). The merchant account information must carry exactly one of a key or a `url` (checked with the same PSP location rule as `generatePixPayload`), and the "Point of Initiation Method" object (`01`) must agree with it: a key belongs to a static payload (`01` absent or `"11"`) and a `url` to a dynamic one (`01` set to `"12"`), so any other pairing returns `null`. A static payload that states an amount must state one greater than zero (`54` set to `0.00` is reserved for the Pix Saque/Troco BR Code, which is out of scope), and in a dynamic payload the amount and the `txid` are ignored, as the manual mandates. Payloads whose location lives in an Unreserved Template (IDs 80 to 99, Pix Automático) are out of scope and return `null`. +Parses a Pix BR Code payload into its fields. The payload is validated by `isValidPixPayload` first, so a malformed structure, a broken CRC or a missing mandatory object returns `null` instead of a partial result. A static payload comes back with `key`, a dynamic one with `url`. The Pix key itself is not validated, since the manual allows a static QR Code built around a key that no longer exists in the DICT; key ownership is only settled at payment time. The "Additional Data Field Template" (ID 62) is mandatory in the BR Code table but optional in the EMV® specification it refers to, so it is accepted when absent. The lengths the manual reserves for the merchant name (25), the merchant city (15), the `txid` (25) and the Pix key field 26-01 (77) are generator side limits, enforced by `generatePixPayload` and not checked here, since payloads in the wild routinely overrun them. The result is typed as `PixPayloadInfo`; `pointOfInitiation` is always present and typed as `PixPointOfInitiation`, `"dynamic"` when the payload carries a PSP location or when the "Point of Initiation Method" object (`01`) is `"12"`, `"static"` otherwise. The merchant account information must carry exactly one of a key or a `url` (checked with the same PSP location rule as `generatePixPayload`); `01` itself is advisory, so it may be absent from either shape and only a value outside `{"11", "12"}` returns `null`. When a payload built around a key carries an amount, that amount must be greater than zero, unless the payload is a Pix Saque BR Code: §2.6 of the Pix manual puts the ISPB of the "facilitador de serviço de saque" in sub-object 26-03 (`fss`), which comes back as `withdrawalFacilitator`, and `54` set to `"0"` or `"0.00"` is accepted alongside it. Rejecting a zero amount without `fss` is a deliberate restriction of this library, not a rule of the manual. A `fss` written next to a PSP location returns `null`: §2.7 of the Manual de Padrões para Iniciação do Pix maps the dynamic QR Code to exactly two sub-objects, `00` (GUI) and `25` (URL), and `fss` belongs to the static template of §2.6. When the payload carries a PSP location the amount and the `txid` are ignored, as the manual mandates. Unreserved Templates (IDs 80 to 99) are ignored: a "QR Code composto" of Pix Automático that also carries a payment location in 26-25 is parsed as an ordinary dynamic payload and its recurrence location is dropped, so a consumer that has to tell the two apart cannot rely on this parser. Only a payload with no Pix template at all in IDs 26 to 51 returns `null`. ```javascript -import { parsePixPayload } from '@brazilian-utils/brazilian-utils'; +import { getPixPayloadInfo } from '@brazilian-utils/brazilian-utils'; -parsePixPayload( +getPixPayloadInfo( '00020126580014br.gov.bcb.pix0136123e4567-e12b-12d1-a456-426655440000' + '5204000053039865802BR5913Fulano de Tal6008BRASILIA62070503***63041D3D' ); // { -// key: '123e4567-e12b-12d1-a456-426655440000', // merchantName: 'Fulano de Tal', -// merchantCity: 'BRASILIA' +// merchantCity: 'BRASILIA', +// pointOfInitiation: 'static', +// key: '123e4567-e12b-12d1-a456-426655440000' // } ``` ## generatePixPayload -Generates the payload of a Pix BR Code. Exactly one of `params.key` or `params.url` must be given (part of `GeneratePixPayloadParams`); `null` is returned when both or neither are given. `url` must be a PSP location as the Bacen manual defines it: a host name with a path, without a scheme (`pix.example.com/qr/v2/1234`); a dynamic payload cannot carry `amount` or `txid`, which belong to the PSP location, and an `amount` that rounds to `0.00` is rejected. +Generates the payload of a Pix BR Code. Exactly one of `params.key` or `params.url` must be given (part of `GeneratePixPayloadOptions`); `null` is returned when both or neither are given. `url` must be a PSP location as the Bacen manual defines it: a host name with a path, without a scheme (`pix.example.com/qr/v2/1234`); a dynamic payload cannot carry `amount` or `txid`, which belong to the PSP location. The amount is written with the two decimal places the BR Code takes, so one that rounds to `0.00` and one that does not survive that round trip (`0.005`, `123.456`) are both rejected rather than written as a different sum. The Pix Saque BR Code, which announces the `fss` of sub-object 26-03, is parsed by `getPixPayloadInfo` but not generated here. -When `params.key` is given, it is normalized to its DICT canonical form by `parsePixKey` and the payload is static. When `params.url` is given instead (the PSP location, without a URL scheme, e.g. `"pix.example.com/qr/v2/1234"`), the payload is dynamic per the Manual de Padrões para Iniciação do Pix: the URL takes the key's place in the "Merchant Account Information" template and the "Point of Initiation Method" object is set to dynamic (`12`); `params.url` can be at most 77 characters. `merchantName`, `merchantCity` and `description` are folded to printable ASCII (accents dropped) and truncated to what the BR Code allows. `parsePixPayload` already parses both shapes, so `parsePixPayload(generatePixPayload({ url, ... }))` round-trips. +When `params.key` is given, it is normalized to its DICT canonical form by `getPixKeyInfo` and the payload is static. When `params.url` is given instead (the PSP location, without a URL scheme, e.g. `"pix.example.com/qr/v2/1234"`), the payload is dynamic per the Manual de Padrões para Iniciação do Pix: the URL takes the key's place in the "Merchant Account Information" template and the "Point of Initiation Method" object is set to dynamic (`12`); `params.url` can be at most 77 characters. `merchantName`, `merchantCity` and `description` are folded to printable ASCII (accents dropped) and truncated to what the BR Code allows. `getPixPayloadInfo` already parses both shapes, so `getPixPayloadInfo(generatePixPayload({ url, ... }))` round-trips. ```javascript import { generatePixPayload } from '@brazilian-utils/brazilian-utils'; @@ -269,46 +274,75 @@ generatePixPayload({ merchantName: 'Fulano', merchantCity: 'Brasília' }); // nu ## isValidNfeKey -Check if a DF-e (Documento Fiscal eletrônico) access key (chave de acesso) is valid. It covers every document that shares the same 44 digit layout: NF-e (modelo 55), NFC-e (modelo 65), CT-e (modelo 57), MDF-e (modelo 58) and CT-e OS (modelo 67, the Conhecimento de Transporte Eletrônico para Outros Serviços of the [Ajuste SINIEF 09/07](https://www.confaz.fazenda.gov.br/legislacao/ajustes/2007/aj_009_07)). Accepts whitespace between digit groups (the common display mask) and the `NFe` prefix found in the `Id` attribute of the document's XML. The emission type (`tpEmis`) must be one of the codes the MOC assigns, 1 to 7 or 9; 8 is not assigned and makes the key invalid. +Check if a DF-e (Documento Fiscal eletrônico) access key (chave de acesso) is valid. It covers every document whose access key is the same 44 digit string: NF-e (modelo 55), NFC-e (65), CT-e (57, the Conhecimento de Transporte Eletrônico instituted by the cláusula primeira of the [Ajuste SINIEF 09/07](https://www.confaz.fazenda.gov.br/legislacao/ajustes/2007/AJ_009_07)), MDF-e (58), CT-e OS (67, the Conhecimento de Transporte Eletrônico para Outros Serviços instituted by the cláusula primeira of the [Ajuste SINIEF 36/19](https://www.confaz.fazenda.gov.br/legislacao/ajustes/2019/AJ036_19)), GTV-e (64, the CT-e Guia de Transporte de Valores instituted by the cláusula primeira of the [Ajuste SINIEF 03/20](https://www.confaz.fazenda.gov.br/legislacao/ajustes/2020/ajuste-sinief-03-20)), BP-e (63), NF3e (66) and NFCom (62). The CF-e-SAT (59) is out: its 44 position "chave de consulta" is composed differently. The 44 digits may be split into the printed groups of 4 by whitespace, `.`, `-` or `/`, a run of them between two groups included, the same interchangeable mask `isValidCpf` and `isValidCnpj` accept; a separator inside a group of 4, or any other character, is rejected instead of being stripped. The `NFe`, `CTe`, `MDFe`, `BPe`, `NF3e` and `NFCom` prefixes found in the `Id` attribute of the document's XML are stripped before that check, along with any whitespace between the prefix and the first group. + +The emission type (`tpEmis`) is checked against the codes the MOC of that model assigns, so the accepted set changes with the model: 1 to 7 and 9 for NF-e and NFC-e, `{1, 3, 4, 5, 7, 8}` for the CT-e, `{1, 5, 7, 8}` for the CT-e OS, `{1, 2, 7, 8}` for the GTV-e, `{1, 2, 3}` for the MDF-e and `{1, 2}` for the BP-e, the NF3e and the NFCom. Code 8, the authorização pela SVC-SP, is assigned by the [CT-e MOC 4.00](https://dfe-portal.svrs.rs.gov.br/CTE/Documentos) only, never by the NF-e one; the domains of the [BP-e](https://dfe-portal.svrs.rs.gov.br/BPE/Documentos), the [NF3e](https://dfe-portal.svrs.rs.gov.br/NF3e/Documentos) and the [NFCom](https://dfe-portal.svrs.rs.gov.br/NFCOM/Documentos) come from their own manuals. For NF-e and NFC-e the numeric code is also checked against rule B03-10 of the NF-e MOC, which forbids the twenty repeated and sequential `cNF` values it lists and a `cNF` equal to the document number. A document number of all zeros is turned down for every model, following the leiaute rather than a choice of this library: `tiposBasico_v4.00.xsd` of the [NF-e schema package](https://dfe-portal.svrs.rs.gov.br/NFE/Documentos) types `nNF` as `TNF`, whose pattern is `[1-9]{1}[0-9]{0,8}`, and the Anexo I of every other model repeats the same regex for its own number field. ```javascript import { isValidNfeKey } from '@brazilian-utils/brazilian-utils'; isValidNfeKey('35170458716523000119550010000000121000123458'); // true (NF-e, SP) isValidNfeKey('NFe35170458716523000119550010000000121000123458'); // true (XML Id prefix) +isValidNfeKey('CTe35170458716523000119570010000000128000123452'); // true (CT-e authorised by the SVC-SP) isValidNfeKey('3517 0458 7165 2300 0119 5500 1000 0000 1210 0012 3458'); // true (masked) +isValidNfeKey('3517.0458.7165.2300.0119.5500.1000.0000.1210.0012.3458'); // true (any of the mask characters) +isValidNfeKey('351 70458716523000119550010000000121000123458'); // false (a separator inside a group of 4) isValidNfeKey('99170458716523000119550010000000121000123458'); // false (invalid cUF) -isValidNfeKey('35170458716523000119550010000000128000123455'); // false (tpEmis 8 is not assigned) +isValidNfeKey('35170458716523000119550010000000128000123455'); // false (the NF-e MOC does not assign tpEmis 8) +isValidNfeKey('35170458716523000119550010000000121000000003'); // false (cNF 00000000, rule B03-10) ``` ## formatNfeKey -Format a DF-e (NF-e, NFC-e, CT-e, MDF-e or CT-e OS) access key into groups of 4 digits separated by spaces, the common display form printed on the DANFE. +Format a DF-e (Documento Fiscal eletrônico) access key into groups of 4 digits separated by spaces, the form every auxiliary document prints it in: the DANFE of the NF-e and the NFC-e, the DACTE of the CT-e, the CT-e OS and the GTV-e, the DAMDFE of the MDF-e, the DABPE of the BP-e, the DANF3E of the NF3e and the DANFE-COM of the NFCom. Like every formatter of this package, the value is read for its digits and grouped as far as they go, so a masked or partial key still being typed is grouped progressively, and anything without a digit (an object, `true`, an object created with `Object.create(null)`) gives `''` instead of throwing. Use `isValidNfeKey` to check a key. `options.pad` (part of `FormatNfeKeyOptions`) left pads the value with zeros up to the 44 digits of a complete access key (default `false`). The parameter is typed as a string because 44 digits are more than a JavaScript number can hold exactly; at runtime a number is read as the string of its digits, like in every formatter of this package. ```javascript import { formatNfeKey } from '@brazilian-utils/brazilian-utils'; formatNfeKey('35170458716523000119550010000000121000123458'); // '3517 0458 7165 2300 0119 5500 1000 0000 1210 0012 3458' + +formatNfeKey('12345'); // '1234 5' + +formatNfeKey('12345', { pad: true }); +// '0000 0000 0000 0000 0000 0000 0000 0000 0000 0001 2345' ``` ## parseNfeKey -Parses a DF-e access key into its fields (state, year, month, taxId, model, series, number, emissionType, code, checkDigit). Accepts the same input forms as `isValidNfeKey` and returns `null` when the key is not valid. The result is typed as `NfeKey`. +Remove the formatting of a DF-e access key (chave de acesso), keep only digits, and cap the result to 44 digits. The `NFe`, `CTe`, `MDFe`, `BPe`, `NF3e` and `NFCom` prefixes the `Id` attribute of the document XML puts in front of the key are stripped first, since `NF3e` carries a digit of its own; use `isValidNfeKey` to check the key and `getNfeKeyInfo` to read its fields. ```javascript import { parseNfeKey } from '@brazilian-utils/brazilian-utils'; -parseNfeKey('35170458716523000119550010000000121000123458'); -// { state: 'SP', year: 2017, month: 4, taxId: '58716523000119', model: '55', +parseNfeKey('3517 0458 7165 2300 0119 5500 1000 0000 1210 0012 3458'); +// '35170458716523000119550010000000121000123458' + +parseNfeKey('NFe35170458716523000119550010000000121000123458'); +// '35170458716523000119550010000000121000123458' +``` + +## getNfeKeyInfo + +Parses a DF-e access key into its fields (stateCode, year, month, taxId, model, series, number, emissionType, code, checkDigit). Accepts the same input forms as `isValidNfeKey` and returns `null` when the key is not valid. The result is typed as `NfeKeyInfo`, whose `model` is an `NfeKeyModel`. NFCom (`'62'`) and NF3e (`'66'`) spend position 36 of the key on `nSiteAutoriz`, the site of the authorizer that received the document, so for those two models the result also carries `authorizationSite` and `code` is 7 digits instead of 8. + +```javascript +import { getNfeKeyInfo } from '@brazilian-utils/brazilian-utils'; + +getNfeKeyInfo('35170458716523000119550010000000121000123458'); +// { stateCode: 'SP', year: 2017, month: 4, taxId: '58716523000119', model: '55', // series: 1, number: 12, emissionType: 1, code: '00012345', checkDigit: 8 } -parseNfeKey('invalid'); // null +getNfeKeyInfo('35170458716523000119620010000000121000123450'); +// { stateCode: 'SP', year: 2017, month: 4, taxId: '58716523000119', model: '62', +// series: 1, number: 12, emissionType: 1, code: '0012345', checkDigit: 0, authorizationSite: 0 } + +getNfeKeyInfo('invalid'); // null ``` ## isValidEmail -Check if email is valid. +Check if email is valid. The accepted set is a practical subset of the WHATWG HTML [valid e-mail address](https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) definition, not of [RFC 5322](https://www.rfc-editor.org/rfc/rfc5322). The local part is limited to letters, digits and `_'+-.`, and may not start with a dot, end with a dot or an apostrophe, or contain two dots in a row. The domain must carry at least one dot, and each dotted label follows the WHATWG production `[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?`, so a label may neither start nor end with a hyphen nor exceed 63 characters; the final label is alphabetic and 2 to 63 letters long, so `user@example.c1` is rejected. Quoted local parts (`"john doe"@example.com`) and address literals (`john@[127.0.0.1]`) are rejected. ```javascript import { isValidEmail } from '@brazilian-utils/brazilian-utils'; @@ -318,12 +352,14 @@ isValidEmail('john.doe@hotmail.com'); // true ## isValidPhone -Check if phone number (mobile or landline) is valid. A Brazilian country code (`+55`, `0055` or a bare `55`) is accepted and removed before validation, under the rule documented in `parsePhone`. `options.accept` (typed as `PhoneType[]`, part of `IsValidPhoneOptions`) picks which kinds of number count as valid and defaults to `['mobile', 'landline']`; add `'service'` to also accept the non-geographic numbers recognized by `isValidServicePhone`, or pass `[]` to accept none. +Check if phone number (mobile or landline) is valid. A Brazilian country code (`+55`, `0055` or a bare `55`) is accepted and removed before validation, under the rule documented in `parsePhone`. `options.accept` (typed as `PhoneType[]`, part of `IsValidPhoneOptions`) picks which kinds of number count as valid and defaults to `['mobile', 'landline']`; add `'service'` to also accept the non-geographic numbers recognized by `isValidServicePhone`, or pass `[]` to accept none. `options.version` (typed as `PhoneVersion`, part of the same type) is forwarded to `isValidMobilePhone` and picks which mobile numbering rule is enforced: `1` (default) the legacy format, whose first number digit may be 6, 7, 8 or 9, and `2` the current one of Resolução Anatel 749/2022, art. 12, I, "a", which accepts 7, 8 or 9 and rejects the `700` prefix. It only affects mobile numbers; landline and service numbers are unaffected. ```javascript import { isValidPhone } from '@brazilian-utils/brazilian-utils'; isValidPhone('11900000000'); // true +isValidPhone('11712345678', { version: 2 }); // true (7, 8 and 9 are all SMP) +isValidPhone('11700123456', { version: 2 }); // false (the 700 series is satellite) isValidPhone('+55 11 98765-4321'); // true (country code accepted) isValidPhone('08001234567'); // false (service numbers rejected by default) isValidPhone('08001234567', { accept: ['service'] }); // true @@ -332,7 +368,7 @@ isValidPhone('11900000000', { accept: [] }); // false ## formatPhone -Format phone number according to Brazilian patterns. `options.mask` (typed as `PhoneMask`) accepts `"sn"` (default, subscriber number only, 9 digits, no DDD), `"nanp"` (DDD + subscriber number, 11 digits), `"e164"` (`"+5511987654321"`), `"international"` (`"+55 11 98765-4321"`, the way a Brazilian number is printed for foreign callers), `"service"` (`"0800 123 4567"` or `"4004-1234"`, the conventional groupings for service numbers) or `"auto"`. `"auto"` picks `"international"` when `value` carries a Brazilian country code (`+55`, `0055` or a bare `55` followed by 10 or 11 digits), `"service"` when `value` is a service number, and otherwise falls back to the digit count: `"nanp"` when `value` has more digits than a bare subscriber number, `"sn"` when it does not. `"e164"` and `"international"` drop the country code from `value` first, under the rule documented in `parsePhone`, and fall back to the `"service"` presentation for a service number, since those have no E.164 form. If `value` includes a DDD, pass `{ mask: 'auto' }` (or `'nanp'`) explicitly, since the default `"sn"` mask assumes no DDD and silently truncates one if present. +Format phone number according to Brazilian patterns. `options.mask` (typed as `PhoneMask`) accepts `"sn"` (default, subscriber number only, 9 digits, no DDD), `"nanp"` (DDD + subscriber number, `"(00) 00000-0000"` for the 11 digits of a mobile and `"(00) 0000-0000"` for the 10 digits of a landline, any other length keeping the 11 digit grouping), `"e164"` (`"+5511987654321"`), `"international"` (`"+55 11 98765-4321"`, the way a Brazilian number is printed for foreign callers), `"service"` (`"0800 123 4567"` or `"4004-1234"`, the conventional groupings for service numbers) or `"auto"`. `"auto"` picks `"international"` when `value` carries a Brazilian country code (`+55`, `0055` or a bare `55` followed by 10 or 11 digits), `"service"` when `value` is a service number, and otherwise falls back to the digit count: `"nanp"` when `value` has more digits than a bare subscriber number, `"sn"` when it does not. `"e164"` and `"international"` drop the country code from `value` first, under the rule documented in `parsePhone`, and fall back to the `"service"` presentation for a service number, since those have no E.164 form. If `value` includes a DDD, pass `{ mask: 'auto' }` (or `'nanp'`) explicitly, since the default `"sn"` mask assumes no DDD and silently truncates one if present. A `mask` outside the union falls back to the default `"sn"` instead of throwing. ```javascript import { formatPhone } from '@brazilian-utils/brazilian-utils'; @@ -340,6 +376,8 @@ import { formatPhone } from '@brazilian-utils/brazilian-utils'; formatPhone('987654321'); // 98765-4321 (default "sn", no DDD) formatPhone('11900000000', { mask: 'nanp' }); // (11) 90000-0000 formatPhone('11900000000', { mask: 'auto' }); // (11) 90000-0000 +formatPhone('1130000000', { mask: 'nanp' }); // (11) 3000-0000 (10 digit landline) +formatPhone('1130000000', { mask: 'auto' }); // (11) 3000-0000 (10 digit landline) formatPhone('11987654321', { mask: 'e164' }); // +5511987654321 formatPhone('+5511987654321', { mask: 'international' }); // +55 11 98765-4321 formatPhone('08001234567', { mask: 'service' }); // 0800 123 4567 @@ -363,14 +401,16 @@ parsePhone('55987654321'); // 55987654321 (area code 55, not mistaken for the +5 ## isValidMobilePhone -Check if mobile phone number is valid. `options.version` (typed as `PhoneVersion`) controls which mobile numbering rule is enforced: `1` (default) is the pre-Resolução Anatel 749/2022 format, kept for 2.3.0 compatibility, whose first number digit (after the DDD) may be 6, 7, 8 or 9; `2` enforces only 9, a stricter subset of the resolution's art. 12 I (Serviço Móvel Pessoal). +Check if mobile phone number is valid. `options.version` (typed as `PhoneVersion`) controls which mobile numbering rule is enforced: `1` (default) is the pre-Resolução Anatel 749/2022 format, kept for 2.3.0 compatibility, whose first number digit (after the DDD) may be 6, 7, 8 or 9; `2` enforces the resolution's art. 12, I, "a", which places 7, 8 and 9 in the Serviço Móvel Pessoal (SMP), so a leading 6 is Reserva Técnica and is rejected. Version `2` also carves out the `700` prefix, which art. 12, II reserves for the Serviço Móvel Global por Satélite rather than SMP, so `isValidMobilePhone('11700123456', { version: 2 })` is `false`; version `1` does not carve it out and accepts it. ```javascript import { isValidMobilePhone } from '@brazilian-utils/brazilian-utils'; isValidMobilePhone('11900000000'); // true isValidMobilePhone('11712345678', { version: 1 }); // true (legacy format) -isValidMobilePhone('11712345678', { version: 2 }); // false (v2 requires 9 as the first digit) +isValidMobilePhone('11712345678', { version: 2 }); // true (7 is SMP as well) +isValidMobilePhone('11612345678', { version: 2 }); // false (6 is Reserva Técnica) +isValidMobilePhone('11700123456', { version: 2 }); // false (the 700 series is satellite) ``` ## isValidLandlinePhone @@ -385,7 +425,7 @@ isValidLandlinePhone('1130000000'); // true ## isValidServicePhone -Check if a phone number is a valid Brazilian service number, dialed without a DDD: the Códigos Não Geográficos `0300`, `0303`, `0500`, `0800` and `0900` (11 digits total), the abbreviated `300X`/`400X` numbers (8 digits), and the 3-digit Códigos de Acesso a Serviços de Utilidade Pública that Anatel has designated (e.g. `190`, `192`; `112` and `911` are accepted too, as mobile-only aliases of `190` that Anatel lists alongside the other 3-digit codes). Only the structure is checked, the number does not have to be assigned to anyone. +Check if a phone number is a valid Brazilian service number, dialed without a DDD: the Códigos Não Geográficos `0300`, `0303`, `0500`, `0800` and `0900` (11 digits total, so the shorter, extinct `0800` + 6 digit form is rejected), the abbreviated `300X`/`400X` numbers (8 digits), and the 3-digit Códigos de Acesso a Serviços de Utilidade Pública that Anatel has designated (e.g. `190`, `192`), whose consolidated table is the Anexo of [Ato Anatel nº 43.151/2004](https://informacoes.anatel.gov.br/legislacao/atos-de-numeracao/2004/1648-ato-43151). `112` and `911` are rejected: Anatel designates neither, and `911` is not even inside the `1N₂N₁` range art. 13 of [Resolução nº 749/2022](https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749) destines to public utility services, so the way handsets route them is a GSM convention rather than a numbering designation. Only the structure is checked: the number does not have to be assigned to anyone, and the `0500` rule that encodes a donation amount in the last two digits is not enforced. Anatel withdrew the 4-digit codes instead of allocating them (art. 43 I of [Resolução nº 86/1998](https://informacoes.anatel.gov.br/legislacao/resolucoes/1998/336-resolucao-86) and art. 2º II of the Ato above both ordered them released), so only the conventional `300X` and `400X` roots are recognised: other "Número Único" carrier prefixes in market use, such as `4020` and `4062`, are out of scope and are rejected. ```javascript import { isValidServicePhone } from '@brazilian-utils/brazilian-utils'; @@ -400,19 +440,19 @@ isValidServicePhone('11987654321'); // false (geographic number) Get the state (and its region) a Brazilian DDD (area code) belongs to, out of the 67 DDDs in use under the Anatel Plano Geral de Numeração. Accepts a string or a non-negative integer number, stripping any non-digit characters before matching. Exports the `AreaCodeInfo` type. -`stateCode` is always a single state: the one that holds all but a handful of the DDD's municipalities. Four DDDs straddle a state border, and for those `stateCodes` lists the other states too. DDD 61 is the widest of them, serving the Distrito Federal and the twelve Goiás municipalities of the Entorno do Distrito Federal (Águas Lindas de Goiás, Cabeceiras, Cidade Ocidental, Cristalina, Formosa, Luziânia, Novo Gama, Padre Bernardo, Planaltina, Santo Antônio do Descoberto, Valparaíso de Goiás and Vila Boa). The other three are 42, shared by Paraná and Porto União (SC), 47, shared by Santa Catarina and Rio Negro (PR), and 49, shared by Santa Catarina and Barracão (PR). +`stateCode` is always a single state: the one the DDD is seated in, the state of the city the code was allocated around, which is not necessarily the state holding most of its municipalities. Four DDDs straddle a state border, and for those `stateCodes` lists the other states too. DDD 61 is the widest of them, serving the Distrito Federal and the twelve Goiás municipalities of the Entorno do Distrito Federal (Águas Lindas de Goiás, Cabeceiras, Cidade Ocidental, Cristalina, Formosa, Luziânia, Novo Gama, Padre Bernardo, Planaltina, Santo Antônio do Descoberto, Valparaíso de Goiás and Vila Boa), so its `stateCode` is `'DF'` even though the Distrito Federal holds only one of its thirteen municipalities, Brasília. The other three are 42, shared by Paraná and Porto União (SC), 47, shared by Santa Catarina and Rio Negro (PR), and 49, shared by Santa Catarina and Barracão (PR), and there the seat does hold every municipality but the one named. ```javascript import { getAreaCodeInfo } from '@brazilian-utils/brazilian-utils'; getAreaCodeInfo('11'); -// { areaCode: 11, stateCode: 'SP', stateName: 'São Paulo', region: 'Sudeste', stateCodes: ['SP'] } +// { areaCode: 11, stateCode: 'SP', stateName: 'São Paulo', regionCode: 'SE', regionName: 'Sudeste', stateCodes: ['SP'] } getAreaCodeInfo(21); -// { areaCode: 21, stateCode: 'RJ', stateName: 'Rio de Janeiro', region: 'Sudeste', stateCodes: ['RJ'] } +// { areaCode: 21, stateCode: 'RJ', stateName: 'Rio de Janeiro', regionCode: 'SE', regionName: 'Sudeste', stateCodes: ['RJ'] } getAreaCodeInfo('61'); -// { areaCode: 61, stateCode: 'DF', stateName: 'Distrito Federal', region: 'Centro-Oeste', stateCodes: ['DF', 'GO'] } +// { areaCode: 61, stateCode: 'DF', stateName: 'Distrito Federal', regionCode: 'CO', regionName: 'Centro-Oeste', stateCodes: ['DF', 'GO'] } getAreaCodeInfo('00'); // null getAreaCodeInfo(-11); // null @@ -453,14 +493,27 @@ isValidLicensePlate('ABC1234EXTRA'); // false (too many characters) ## isValidRenavam -Check if RENAVAM (Registro Nacional de Veículos Automotores) is valid. Supports both the old format (9 digits) and the new format (11 digits). +Check if RENAVAM (Registro Nacional de Veículos Automotores) is valid. Supports both the old format (9 digits) and the new format (11 digits). Any spaces, dots and hyphens around/between the digits are ignored, but any other character, a letter in particular, makes the value invalid. A registration whose digits are all the same is rejected as well. ```javascript import { isValidRenavam } from '@brazilian-utils/brazilian-utils'; isValidRenavam('639884962'); // true (9 digits, old format) isValidRenavam('00639884962'); // true (11 digits, new format) +isValidRenavam('0063988.4962'); // true (dots and hyphens are ignored) isValidRenavam('12345678901'); // false (invalid checksum) +isValidRenavam('00000000000'); // false (repeated digits) +isValidRenavam('ab00639884962'); // false (letters are rejected) +``` + +## generateRenavam + +Generate a valid random RENAVAM: the 11 digit form, ten base digits plus the check digit. A base whose digits are all the same is drawn again, since `isValidRenavam` rejects those. Uses `Math.random()` internally, so it is not cryptographically secure. + +```javascript +import { generateRenavam } from '@brazilian-utils/brazilian-utils'; + +generateRenavam(); // '12345678900' ``` ## isValidPis @@ -475,7 +528,7 @@ isValidPis('12056412547'); // false ## formatPis -Format PIS number. +Format PIS number. `options.pad` (part of `FormatPisOptions`) left-pads the value with zeros to the full 11 digits before masking (default `false`). ```javascript import { formatPis } from '@brazilian-utils/brazilian-utils'; @@ -496,12 +549,13 @@ parsePis('123.45678.90-1'); // 12345678901 ## formatCep -Format CEP ([brazilian postal code](https://en.wikipedia.org/wiki/C%C3%B3digo_de_Endere%C3%A7amento_Postal)). +Format CEP ([brazilian postal code](https://en.wikipedia.org/wiki/C%C3%B3digo_de_Endere%C3%A7amento_Postal)). `options.pad` (part of `FormatCepOptions`) left-pads the value with zeros to the full 8 digits before masking (default `false`); a CEP that starts with `0` given as a number loses that zero, so pass it as a string or use `pad`. ```javascript import { formatCep } from '@brazilian-utils/brazilian-utils'; formatCep('92500000'); // 92500-000 +formatCep('9250000', { pad: true }); // 09250-000 ``` ## parseCep @@ -516,7 +570,7 @@ parseCep('92500-000'); // 92500000 ## getAddressInfoByCep -Fetch address information for a given CEP using multiple providers. Defaults to `['viacep', 'brasilapi']`. The `'widenet'` provider is deprecated (its endpoint no longer responds) and excluded from the default list, but it can still be requested explicitly via `options.providers` (typed as `CepProvider[]`). The resolved address is typed as `AddressInfo`. +Fetch address information for a given CEP using multiple providers. Defaults to `['viacep', 'brasilapi']`. The `'widenet'` provider is deprecated (its endpoint no longer responds) and excluded from the default list, but it can still be requested explicitly via `options.providers` (typed as `CepProvider[]`). The resolved address is typed as `AddressInfo`. A transient network failure is retried twice per provider, with a 250 ms linear backoff (250 ms, then 500 ms), so a provider that keeps failing is tried up to 3 times and adds about 750 ms before its own failure lands; an HTTP error status or a non-retryable failure is not retried. The providers are started together and raced with `Promise.any`, not queried one after the other, so those retries delay nothing for the other providers, only the moment an all-failed rejection can surface. An `options.providers` that names no known provider rejects with `GetAddressInfoByCepValidationError` ("Nenhum provedor válido especificado"): an empty array, an array of unknown names, and a value that is not an array at all, `null` included. With `providers: ['brasilapi']`, a CEP BrasilAPI does not know rejects with `GetAddressInfoByCepNotFoundError`, since BrasilAPI signals a miss with HTTP 404; any other error status is still a `GetAddressInfoByCepServiceError`. All three extend `GetAddressInfoByCepError`, the base class of every error this util rejects with, so a single `catch` on it covers all of them. ```javascript import { getAddressInfoByCep } from '@brazilian-utils/brazilian-utils'; @@ -526,32 +580,37 @@ const address = await getAddressInfoByCep('01310100'); // { cep: '01310100', state: 'SP', city: 'São Paulo', neighborhood: 'Bela Vista', street: 'Avenida Paulista' } // Using specific providers -const address = await getAddressInfoByCep('01310-100', { +const addressFromProviders = await getAddressInfoByCep('01310-100', { providers: ['viacep', 'brasilapi'] }); // Using number input (will be padded automatically) -const address = await getAddressInfoByCep(1310100); +const addressFromNumber = await getAddressInfoByCep(1310100); ``` ## isValidProcessoJuridico -Validate the processo jurídico number according to [CNJ's definition](https://atos.cnj.jus.br/atos/detalhar/119). +Validate the processo jurídico number according to [CNJ's definition](https://atos.cnj.jus.br/atos/detalhar/119): the `NNNNNNN-DD.AAAA.J.TR.OOOO` layout, the `DD` check digits and the `J`/`TR` pair, which must identify an existing órgão and tribunal from the closed lists defined by Resolução CNJ nº 65/2008, so a number carrying a correct check digit but a court that does not exist is rejected. The closed lists come from art. 1º, § 4º and § 5º of the resolution, § 5º, III in the wording Resolução CNJ nº 477/2022 gave it to seat the TRF da 6ª Região. The unidade de origem (`OOOO`) is only read as four digits, since art. 1º, § 6º leaves its codification to each tribunal and publishes no central list. The CNJ mask separators (whitespace, `.` and `-`) are accepted between the fields, and whitespace around the value is ignored, but any other character, a letter in particular, makes the value invalid. ```javascript import { isValidProcessoJuridico } from '@brazilian-utils/brazilian-utils'; isValidProcessoJuridico('00020802520125150049'); // true +isValidProcessoJuridico('0002080-25.2012.5.15.0049'); // true (CNJ mask) +isValidProcessoJuridico('0000100-68.2008.4.06.0000'); // true (TRF da 6ª Região) +isValidProcessoJuridico('0000100-23.2008.8.28.0000'); // false (no 28th Tribunal de Justiça) +isValidProcessoJuridico('ab00020802520125150049'); // false (letters are rejected) ``` ## formatProcessoJuridico -Format the processo jurídico number according to [CNJ's definition](https://atos.cnj.jus.br/atos/detalhar/119) (mask `NNNNNNN-DD.AAAA.J.TR.OOOO`). +Format the processo jurídico number according to [CNJ's definition](https://atos.cnj.jus.br/atos/detalhar/119) (mask `NNNNNNN-DD.AAAA.J.TR.OOOO`). `options.pad` (part of `FormatProcessoJuridicoOptions`) left-pads the value with zeros to the full 20 digits before masking (default `false`). ```javascript import { formatProcessoJuridico } from '@brazilian-utils/brazilian-utils'; formatProcessoJuridico('00020802520125150049'); // 0002080-25.2012.5.15.0049 +formatProcessoJuridico('20802520125150049', { pad: true }); // 0002080-25.2012.5.15.0049 ``` ## parseProcessoJuridico @@ -566,7 +625,7 @@ parseProcessoJuridico('0002080-25.2012.5.15.0049'); // 00020802520125150049 ## isValidIe -Check if inscrição estadual (state registration) is valid. The state code is case-insensitive. Notable per-state rules: GO accepts prefixes `10`, `11` and `15`; PA accepts `15` and `75`-`79`; MS accepts `28` and `50`; SP has a produtor rural pattern `P0MMMSSSSD000`; TO uses 11-digit type codes (`01`, `02`, `03`, `99`). +Check if inscrição estadual (state registration) is valid. The state code is case-insensitive. Notable per-state rules: GO accepts prefixes `10`, `11` and `15`; PA accepts `15` and `75`-`79`; MS accepts `28` and `50`; SP has a produtor rural pattern `P0MMMSSSSD000`; TO uses 11-digit type codes (`01`, `02`, `03`, `99`). TO also accepts a 9-digit form, applying the same modulus 11 rule to the first eight digits; the SINTEGRA page documents only the 11-digit one, so that shape is 2.3.0 behaviour kept for compatibility rather than a published rule. An all-zero registration is accepted wherever the published formula yields a check digit of 0 for it (AM, BA with 8 or 9 digits, CE, ES, MG, MT, PB, PE, PI, PR, RJ, RS, SC, SE, SP and TO with 9 digits), unlike `isValidCpf` and `isValidCnpj`, which reject repeated digits. AM is on that list through the second branch of its published formula only: the page's first branch, `Se Soma < 11 Então Dígito = 11 - Soma`, gives 11 for an all-zero registration, while the `resto <= 1 ⇒ 0` branch, the one implemented here, gives 0. ```javascript import { isValidIe } from '@brazilian-utils/brazilian-utils'; @@ -583,11 +642,11 @@ Banks validated by their published check digit algorithm: | Bank | Code | Agency | Account | Notes | | --- | --- | --- | --- | --- | -| Banco do Brasil | `001` | 4-5 digits | 8-10 digits | mod11 with weights 9..2; `digit` may be `"X"` | +| Banco do Brasil | `001` | 4-5 digits | 8-10 digits | mod11 with weights 2..9 cycling from the right; `digit` may be `"X"` | | Santander | `033` | 4 digits | 8 digits | weights `9,7,3,1,0,0,9,7,1,3,1,9,7,3` over agency + `"00"` + account, tens discarded | | Banrisul | `041` | 4 digits | 9 digits | weights `3,2,4,7,6,5,4,3,2`; remainder 0 gives `0` and remainder 1 gives `6`; `account` is tipo (2 digits) + conta (7 digits) | | Caixa Econômica Federal | `104` | 4 digits | 11 digits | mod11 over agency + account; `account` is operação (3 digits) + conta (8 digits) | -| Bradesco | `237` | 4 digits | 7 digits | mod11 with weights 2..7; `digit` may be `"P"` (often rendered as `"0"`) | +| Bradesco | `237` | 4 digits | 7 digits | mod11 with weights 2..7 cycling from the right; remainder 0 gives `0` and remainder 1 gives `"P"` | | Nubank | `260` | 4 digits | 5-13 digits | Verhoeff check digit over the account, leading zeros dropped | | Itaú Unibanco | `341` | 4 digits | 5 digits | mod10 over agency + account | | HSBC / Kirton Bank | `399` | 4 digits | 6 digits | weights `8,9,2,3,4,5,6,7,8,9` over agency + account; remainder 10 gives `0` | @@ -597,17 +656,15 @@ Banks validated by structure only, because they publish no check digit rule. The | Bank | Code | | Bank | Code | | --- | --- | --- | --- | --- | -| Inter | `077` | | PicPay | `380` | -| Ailos | `085` | | Cora | `403` | -| XP | `102` | | Pan | `623` | -| Unicred | `136` | | BV | `655` | -| Stone | `197` | | Daycoval | `707` | -| BTG Pactual | `208` | | Modal | `746` | -| Original | `212` | | Sicredi | `748` | -| PagBank | `290` | | Sicoob | `756` | -| BMG | `318` | | | | -| Mercado Pago | `323` | | | | -| C6 | `336` | | | | +| Inter | `077` | | Mercado Pago | `323` | +| Ailos | `085` | | C6 | `336` | +| XP | `102` | | PicPay | `380` | +| Unicred | `136` | | Cora | `403` | +| Stone | `197` | | Pan | `623` | +| BTG Pactual | `208` | | BV | `655` | +| Original | `212` | | Daycoval | `707` | +| PagBank | `290` | | Sicredi | `748` | +| BMG | `318` | | Sicoob | `756` | When `digit` has 2 characters, the generic fallback chains mod10 followed by mod11 over the account, the same way CPF/CNPJ check digits are chained. @@ -710,7 +767,7 @@ getBankByCode('999'); // null ## getBankByIspb -Look a Brazilian bank up by its ISPB (Identificador do Sistema de Pagamentos Brasileiro), the 8 digit code published by Banco Central do Brasil in the [STR participants list](https://www.bcb.gov.br/content/estabilidadefinanceira/str1/ParticipantesSTR.csv). Every SPB participant has an ISPB, but this dataset only carries the institutions that also have a COMPE code, so an ISPB whose institution has no COMPE code of its own returns `null`. Accepts both `string` and `number` input, with or without leading zeros. Returns a fresh copy (typed as `Bank`) of the matching bank, or `null` when no bank has that ISPB. +Look a Brazilian bank up by its ISPB (Identificador do Sistema de Pagamentos Brasileiro), the 8 digit code published by Banco Central do Brasil in the [STR participants list](https://www.bcb.gov.br/content/estabilidadefinanceira/str1/ParticipantesSTR.csv). Every SPB participant has an ISPB, but this dataset only carries the institutions that also have a COMPE code, so an ISPB whose institution has no COMPE code of its own returns `null`. Accepts both `string` and `number` input, with or without leading zeros, so `getBankByIspb(0)` finds the same bank as `getBankByIspb('00000000')`. The dataset is generated from that CSV, falling back to [BrasilAPI](https://brasilapi.com.br/api/banks/v1) when the Bacen request fails. Returns a fresh copy (typed as `Bank`) of the matching bank, or `null` when no bank has that ISPB. ```javascript import { getBankByIspb } from '@brazilian-utils/brazilian-utils'; @@ -722,21 +779,22 @@ getBankByIspb('99999999'); // null ## isValidIban -Check if a Brazilian IBAN (International Bank Account Number) is valid, per Bacen's [Diretrizes de Implementação do IBAN no Brasil](https://www.bcb.gov.br/content/estabilidadefinanceira/Documents/sistema_pagamentos_brasileiro/IBAN-Guidelines_%20port.pdf) (Circular BCB nº 3.625/2013): `BR` + 2 ISO 7064 MOD 97-10 check digits + 8 digit ISPB + 5 digit branch + 10 digit account + 1 letter account type (any letter, usually `C` for conta corrente or `P` for conta poupança) + 1 alphanumeric owner indicator, 29 characters total. Only Brazilian IBANs (country code `BR`) are recognized; any other country returns `false`, since this package does not carry the field layout of the other 90+ ISO 13616 countries. Accepts the usual grouping spaces and is case-insensitive. The value has to be written in the ISO 13616 print format: letters and digits in groups separated by a single space, with optional surrounding whitespace. Any other character makes the value something other than an IBAN, so it is rejected instead of being stripped. +Check if a Brazilian IBAN (International Bank Account Number) is valid, per Bacen's [Diretrizes de Implementação do IBAN no Brasil](https://www.bcb.gov.br/content/estabilidadefinanceira/Documents/sistema_pagamentos_brasileiro/IBAN-Guidelines_%20port.pdf) (Circular BCB nº 3.625/2013): `BR` + 2 ISO 7064 MOD 97-10 check digits + 8 digit ISPB + 5 digit branch + 10 digit account + 1 letter account type (any letter, usually `C` for conta corrente or `P` for conta poupança) + 1 owner indicator (`1` for the first or only holder up to `9` for the ninth, then `A` to `Z` from the tenth, so `0` is rejected), 29 characters total. Only Brazilian IBANs (country code `BR`) are recognized; any other country returns `false`, since this package does not carry the field layout of the other 90+ ISO 13616 countries. Is case-insensitive and accepts both forms an IBAN is written in: compact (`'BR1500000000000010932840814P2'`) or in the ISO 13616 print format, letters and digits in groups of 4 (the last one shorter), with optional surrounding whitespace either way. The groups may be split by whitespace, `.`, `-` or `/`, the interchangeable mask characters `isValidCpf` and `isValidCnpj` accept. Only a separator away from a group boundary, a run of separators (ISO 13616 prints a single one) or a character outside letters and digits makes the value something other than an IBAN, so it is rejected instead of being stripped. ```javascript import { isValidIban } from '@brazilian-utils/brazilian-utils'; isValidIban('BR1500000000000010932840814P2'); // true isValidIban('BR15 0000 0000 0000 1093 2840 814P 2'); // true (grouping spaces) +isValidIban('BR15-0000-0000-0000-1093-2840-814P-2'); // true (any of the mask characters) isValidIban('BR1500000000000010932840814P3'); // false (bad check digits) -isValidIban('BR1500000000000010932840814P-2'); // false (hyphens are not part of an IBAN) +isValidIban('BR15 000 00000 0000 1093 2840 814P 2'); // false (a separator inside a group) isValidIban('DE89370400440532013000'); // false (non Brazilian IBAN) ``` ## formatIban -Format a Brazilian IBAN by grouping it in blocks of 4 characters, the ISO 13616 "print" presentation used on statements and bank forms. Does not validate the check digits or the field layout; formats whatever is given, up to the 29 character length of a Brazilian IBAN, as far as it goes, so the function can also be used as an input mask. Use `isValidIban` to check validity. The value still has to be written in the ISO 13616 print format (letters and digits in groups separated by a single space, with optional surrounding whitespace); any other character returns an empty string instead of being quietly dropped. +Format an IBAN in the ISO 13616 print grouping, blocks of 4 characters, the presentation used on statements and bank forms. Does not validate the check digits or the field layout; formats whatever is given, up to the 29 character length of a Brazilian IBAN, as far as it goes, so the function can also be used as an input mask, and an IBAN of another country is grouped the same way up to that length. Use `isValidIban` to check validity. The value may be compact (`'BR1500000000000010932840814P2'`), already in the ISO 13616 print format or a partial value still being typed (`'BR15'`); like every formatter of this package, it is read for its letters and digits and grouped as far as they go, any other character (a hyphen, a dot, extra whitespace) is dropped and the letters are uppercased. Only a value that is not a string gives an empty string. ```javascript import { formatIban } from '@brazilian-utils/brazilian-utils'; @@ -744,17 +802,28 @@ import { formatIban } from '@brazilian-utils/brazilian-utils'; formatIban('BR1500000000000010932840814P2'); // 'BR15 0000 0000 0000 1093 2840 814P 2' formatIban('br1500000000000010932840814p2'); // 'BR15 0000 0000 0000 1093 2840 814P 2' formatIban('BR15'); // 'BR15' -formatIban('BR1500000000000010932840814P-2'); // '' (hyphens are not part of an IBAN) +formatIban('BR15 0000-0000.0000/1093 2840 814P-2'); // 'BR15 0000 0000 0000 1093 2840 814P 2' (only letters and digits are read) ``` ## parseIban -Parses a Brazilian IBAN into its fields: 2 (country code, always `BR`) + 2 (ISO 7064 MOD 97-10 check digits) + 8 (ISPB) + 5 (branch) + 10 (account) + 1 (account type, any letter, usually `C` for conta corrente or `P` for conta poupança) + 1 (owner indicator). Accepts the same input forms as `isValidIban` (grouping spaces, lowercase) and returns `null` whenever `isValidIban` would return `false`, including a value carrying any character other than letters, digits and the grouping spaces of the print format. The result is typed as `Iban`, whose `accountType` is a `string`. +Remove IBAN formatting, keep the letters and digits, uppercase the result, and cap it to the 29 characters of a Brazilian IBAN. An IBAN carries letters as well as digits, so the value is read the way `parsePassport` reads a passport number; use `isValidIban` to check the check digits and `getIbanInfo` to read the fields. ```javascript import { parseIban } from '@brazilian-utils/brazilian-utils'; -parseIban('BR1500000000000010932840814P2'); +parseIban('BR15 0000 0000 0000 1093 2840 814P 2'); // 'BR1500000000000010932840814P2' +parseIban('br15-0000.0000/0000 1093 2840 814p-2'); // 'BR1500000000000010932840814P2' +``` + +## getIbanInfo + +Parses a Brazilian IBAN into its fields: 2 (country code, always `BR`) + 2 (ISO 7064 MOD 97-10 check digits) + 8 (ISPB) + 5 (branch) + 10 (account) + 1 (account type, any letter, usually `C` for conta corrente or `P` for conta poupança) + 1 (owner indicator, `1` to `9` then `A` to `Z`). Only Brazilian IBANs are supported: the field layout of the other ISO 13616 countries is out of scope, so a well-formed non `BR` IBAN also returns `null`. Accepts the same input forms as `isValidIban`, compact or in the ISO 13616 print format (groups of 4 split by a single whitespace, `.`, `-` or `/`), in either case with optional surrounding whitespace and in any case, and returns `null` whenever `isValidIban` would return `false`, including a value carrying a separator away from a group boundary, a run of separators or any character other than letters and digits. The result is typed as `IbanInfo`, whose `accountType` is a `string`. + +```javascript +import { getIbanInfo } from '@brazilian-utils/brazilian-utils'; + +getIbanInfo('BR1500000000000010932840814P2'); // { // countryCode: 'BR', // checkDigits: '15', @@ -765,13 +834,13 @@ parseIban('BR1500000000000010932840814P2'); // owner: '2' // } -parseIban('DE89370400440532013000'); // null (non Brazilian IBAN) -parseIban('BR1500000000000010932840814P-2'); // null (hyphens are not part of an IBAN) +getIbanInfo('DE89370400440532013000'); // null (non Brazilian IBAN) +getIbanInfo('BR15 000 00000 0000 1093 2840 814P 2'); // null (a separator inside a group) ``` ## isValidCreditCard -Check if a payment card number is valid using the Luhn algorithm ([ISO/IEC 7812-1](https://www.iso.org/standard/70484.html)). Accepts the usual mask characters (spaces, hyphens) between digits. Performs no brand detection (Visa, Mastercard, Amex...), issuer range lookup or expiration/CVV checks, only the digit count (12 to 19) and the Luhn check digit. A `number` is only accepted when it is a non-negative safe integer: anything above `Number.MAX_SAFE_INTEGER` (2^53 - 1, 16 digits) has already been rounded to a different number before the function sees it, so pass a longer PAN as a string. +Check if a payment card number is valid using the Luhn algorithm ([ISO/IEC 7812-1](https://www.iso.org/standard/70484.html)). Accepts the usual mask characters (whitespace, `.`, `-` and `/`, the interchangeable set `isValidCpf` and `isValidCnpj` accept) between any two digits and whitespace around the value; any other character makes the value invalid. They are accepted between any two digits rather than at fixed positions because the printed grouping of a PAN changes with the brand (4-4-4-4 for Visa and Mastercard, 4-6-5 for American Express, 4-6-4 for Diners Club), so there is no single layout to pin them to. Performs no brand detection (Visa, Mastercard, Amex...), issuer range lookup or expiration/CVV checks, only the digit count (12 to 19) and the Luhn check digit. A `number` is only accepted when it is a non-negative safe integer: anything above `Number.MAX_SAFE_INTEGER` (2^53 - 1, 16 digits) has already been rounded to a different number before the function sees it, so pass a longer PAN as a string. A value whose digits are all the same (`'0000000000000000'`) is rejected even when it passes the Luhn check, the way every other validator of this package rejects a repeated-digit document (`isValidCpf('00000000000')`, `isValidCns`, `isValidCaepf`, `isValidCei`). ```javascript import { isValidCreditCard } from '@brazilian-utils/brazilian-utils'; @@ -780,30 +849,52 @@ isValidCreditCard('4111111111111111'); // true (Visa test number) isValidCreditCard('5555555555554444'); // true (Mastercard test number) isValidCreditCard('378282246310005'); // true (American Express test number) isValidCreditCard('4111 1111 1111 1111'); // true (spaced mask) +isValidCreditCard('4111.1111/1111-1111'); // true (any of the mask characters) isValidCreditCard('4111111111111112'); // false (bad check digit) +isValidCreditCard('0000000000000000'); // false (every digit the same, though the Luhn check passes) +isValidCreditCard('4111a1111b1111c1111'); // false (letters between the digits) isValidCreditCard(4111111111111111111); // false (above 2^53 - 1, pass it as a string) ``` ## capitalize -Transforms the first letter into a capital one of each word ignoring prepositions. Words are separated by whitespace, by `-` and by `/`, so `'MOGI-GUAÇU'` becomes `'Mogi-Guaçu'` and `'SANTANA/RS'` becomes `'Santana/Rs'`. Every run of whitespace (tabs, newlines, repeated spaces) collapses into a single space, and the leading and trailing whitespace is dropped. `options.upperCaseWords` defaults to `[]`, so no acronym is upper-cased unless you list it, and the comparison against both `upperCaseWords` and `lowerCaseWords` is case-insensitive (pt-BR locale). Options are typed as `CapitalizeOptions`. +Transforms the first letter into a capital one of each word, the way a Brazilian name, company name or address is written, with no options needed. Words are separated by whitespace, by `-` and `/`, by the apostrophe (`'d'oeste'` becomes `'d'Oeste'`) and by punctuation that touches a word (`'(empresa)'` becomes `'(Empresa)'`, `'bairro:centro'` becomes `'Bairro:Centro'`), so `'MOGI-GUAÇU'` becomes `'Mogi-Guaçu'`; the separators are kept where they are. Every run of whitespace (tabs, newlines, repeated spaces) collapses into a single space, and the leading and trailing whitespace is dropped. The particles of foreign-origin names (`del`, `della`, `di`, `du`, `van`, `von`, `der`, `den`) stay lower case like the Portuguese prepositions, and so does the elided `d'`, wherever it appears, whenever an apostrophe and a word follow it (`'dias d'ávila'` becomes `'Dias d'Ávila'`); a single letter written right after an apostrophe is the English possessive and stays lower case too (`"bob's"` becomes `"Bob's"`). + +`options.lowerCaseWords` defaults to the Portuguese prepositions, articles and conjunctions that stay in lower case inside a proper name (`de`, `da`, `do`, `e`, ...), and they are only written in lower case when they link two words: one of them that is the first word, that ends the value, or that is followed by punctuation is a designator instead and keeps its capital (`'rua a, 100'` becomes `'Rua A, 100'` and `'condomínio a, quadra d, lote o'` becomes `'Condomínio A, Quadra D, Lote O'`). `options.upperCaseWords` defaults to the company designations and document abbreviations written in upper case in Brazilian usage (`LTDA`, `S.A.`, `S/A`, `S.S.`, `S/S`, `ME`, `EPP`, `MEI`, `EIRELI`, `CIA`, `SCP`, `CNPJ`, `CPF`, `RG`, `CEP`, `UF`) plus the roman numerals that appear in names and addresses (`II` through `XXIII`, except `VI`, which collides with the pt-BR verb form "vi"). `SA` without punctuation is deliberately absent, since it is indistinguishable from the surname "Sá" typed without its accent, while `ME` is also the pronoun "me", so it is only written in upper case in the designation position, as the last word of the value (`'fulano comércio me'` becomes `'Fulano Comércio ME'`) or right before another designation (`'fulano me epp'` becomes `'Fulano ME EPP'`); anywhere else it is an ordinary word (`'diga-me a verdade'` becomes `'Diga-Me a Verdade'`, `'não-me-toque'` becomes `'Não-Me-Toque'`). `S/A` and `S/S` are matched across the slash even though a slash separates words. A two letter word that follows a `/` is upper-cased when it is the code of a Brazilian state (`'porto alegre/rs'` becomes `'Porto Alegre/RS'`); that rule is structural and stays on even when `upperCaseWords` is given, while a state code that does not follow a `/` is left alone. + +Either list given in `options` replaces its default entirely, and the comparison against both is case-insensitive (pt-BR locale). Options are typed as `CapitalizeOptions`. ```javascript import { capitalize } from '@brazilian-utils/brazilian-utils'; -capitalize('josé e maria'); // José e Maria +capitalize('jose da silva'); // Jose da Silva +capitalize('JOSÉ DA SILVA'); // José da Silva +capitalize('empresa ltda'); // Empresa LTDA +capitalize('banco do brasil s.a.'); // Banco do Brasil S.A. +capitalize('casa de carnes s/a'); // Casa de Carnes S/A ("S/A" is matched across the slash) +capitalize('mogi-guaçu'); // Mogi-Guaçu ("-" starts a new word) +capitalize("santa bárbara d'oeste"); // Santa Bárbara d'Oeste ("'" starts a new word, "d" stays lower case) +capitalize("bob's"); // Bob's (a single letter after an apostrophe is the English possessive) +capitalize('rua a, 100'); // Rua A, 100 (a preposition followed by punctuation is a designator) +capitalize('fulano comércio me'); // Fulano Comércio ME ("ME" as the last word is the designation) +capitalize('não-me-toque'); // Não-Me-Toque (anywhere else "me" is an ordinary word) +capitalize('(empresa) ltda'); // (Empresa) LTDA +capitalize('luiz von schmidt'); // Luiz von Schmidt +capitalize('santana/rs'); // Santana/RS ("RS" is a state code right after a "/") +capitalize('porto alegre/rs'); // Porto Alegre/RS +capitalize('santana rs'); // Santana Rs (no "/", so "rs" is just a word) +capitalize('rua xv de novembro'); // Rua XV de Novembro (roman numeral, "de" stays lower case) +capitalize('joão paulo ii'); // João Paulo II +capitalize('de'); // De (a preposition keeps its capital when it is the first word) +capitalize('empresa ltda', { upperCaseWords: [] }); // Empresa Ltda (the list given replaces the default one) capitalize('josé Ama MARIA', { lowerCaseWords: ['ama'] }); // José ama Maria -capitalize('doc inválido', { upperCaseWords: ['DOC'] }); // DOC Inválido -capitalize('MOGI-GUAÇU'); // Mogi-Guaçu ("-" starts a new word) -capitalize('SANTANA/RS', { upperCaseWords: ['RS'] }); // Santana/RS ("/" starts a new word, so "RS" matches) -capitalize('empresa ltda'); // Empresa Ltda (no default acronyms) -capitalize('empresa ltda', { upperCaseWords: ['LTDA'] }); // Empresa LTDA (case-insensitive match) +capitalize('doc inválido', { upperCaseWords: ['DOC'] }); // DOC Inválido (case-insensitive match) capitalize(' josé maria '); // José Maria (every run of whitespace, tabs and newlines included, collapses into one space) ``` ## formatCurrency -Formats an integer or float to a string in the BRL pattern. A `number` is formatted as-is (sign and decimals preserved). A `string` input is read by the same rule as `parseCurrency`, except that a value written without any separator stays in whole units: the last `,` or `.` followed by 1 to 2 digits is the decimal separator, every other `,` or `.` is a thousands separator, and a `-` written before the first digit is preserved. So `'1.234,56'` formats as `1.234,56`, `'-10.5'` as `-10,50` and `'1234'` as `1.234,00`. `precision` is clamped to `0..20` (the range `Intl.NumberFormat` accepts) and defaults to 2. A value that is not a finite number (`NaN`, `Infinity`, `-Infinity`) formats as an empty string. Options are typed as `FormatCurrencyOptions`. +Formats an integer or float to a string in the BRL pattern. A `number` is formatted as-is (sign and decimals preserved). A `string` input is read by the same rule as `parseCurrency`, except that a value written without any separator stays in whole units: the last `,` or `.` followed by 1 to 2 digits (or up to `precision` digits, when that is larger) is the decimal separator, every other `,` or `.` is a thousands separator, and a `-` written before the first digit is preserved. So `'1.234,56'` formats as `1.234,56`, `'-10.5'` as `-10,50` and `'1234'` as `1.234,00`. `precision` is clamped to `0..20` (the package limit, the bound Node 20 still enforces on `Intl.NumberFormat`), defaults to 2, and falls back to 2 when it is not a finite number. A value that is not a finite number (`NaN`, `Infinity`, `-Infinity`) formats as an empty string, and so does a value that cannot be coerced to a number (a symbol, a plain object, a null-prototype object); `null`, arrays and booleans go through `Number()` as in 2.3.0. `options.symbol` prefixes the result with the `R$` currency symbol (default `false`). Options are typed as `FormatCurrencyOptions`. ```javascript import { formatCurrency } from '@brazilian-utils/brazilian-utils'; @@ -821,7 +912,7 @@ formatCurrency(Number.NaN); // "" (non finite numbers format as an empty string) ## parseCurrency -Transforms a string to an integer or float format. The last `,` or `.` followed by 1 to 2 digits (or up to `precision` digits, when that is larger) is the decimal separator; every other `,` or `.` is a thousands separator. So `'R$ 1.234,56'` parses to `1234.56`, `'R$ 1.234'` to `1234`, `'1,5'` to `1.5` and `'12.34'` to `12.34`. A value written without any separator keeps the cents convention and is divided by `10 ** precision`, so `'1234'` parses to `12.34`. A `-` written before the first digit is preserved, so `'-R$ 1,00'` parses to `-1`. `precision` (default 2, clamped to `0..20`) controls how many digits are treated as minor units. Options are typed as `ParseCurrencyOptions`. +Transforms a string to an integer or float format. The last `,` or `.` followed by 1 to 2 digits (or up to `precision` digits, when that is larger) is the decimal separator; every other `,` or `.` is a thousands separator. So `'R$ 1.234,56'` parses to `1234.56`, `'R$ 1.234'` to `1234`, `'1,5'` to `1.5` and `'12.34'` to `12.34`. A value written without any separator keeps the cents convention and is divided by `10 ** precision`, so `'1234'` parses to `12.34`. A `-` written before the first digit is preserved, so `'-R$ 1,00'` parses to `-1`. `precision` (default 2, clamped to `0..20`, and falling back to 2 when it is not a finite number) controls how many digits are treated as minor units. Options are typed as `ParseCurrencyOptions`. ```javascript import { parseCurrency } from '@brazilian-utils/brazilian-utils'; @@ -839,7 +930,7 @@ parseCurrency(''); // 0 ## convertNumberToWords -Formats an integer as its Brazilian Portuguese cardinal number words ("por extenso"), e.g. `1235` becomes `"mil, duzentos e trinta e cinco"`. Only integers from `-999999999999999` to `999999999999999` (999 trillion in absolute value) are supported; anything outside that range, `NaN` or a non-finite value returns `""`. A non-integer `value` is truncated toward zero before conversion. `options.gender` (part of `ConvertNumberToWordsOptions`) agrees "um/dois" and the hundreds group ("duzentos/duzentas", etc.) with the noun the number qualifies, defaulting to `"masculine"`. `options.case` sets the letter case of the result: `"lower"` (default, unchanged), `"sentence"` (capitalizes only the first letter) or `"upper"` (uppercases everything with the "pt-BR" locale, keeping accents, e.g. "três" -> "TRÊS"). An invalid `gender`/`case` value is ignored and the default is used. +Formats an integer as its Brazilian Portuguese cardinal number words ("por extenso"), e.g. `1235` becomes `"mil duzentos e trinta e cinco"`. Only integers from `-999999999999999` to `999999999999999` (999 trillion in absolute value) are supported; anything outside that range, `NaN` or a non-finite value returns `""`. A non-integer `value` is truncated toward zero before conversion. `options.gender` (part of `ConvertNumberToWordsOptions`) agrees "um/dois" and the hundreds group ("duzentos/duzentas", etc.) with the noun the number qualifies, defaulting to `"masculine"`. An invalid `gender` value is ignored and the default is used. The result is always lowercase; apply any other casing to it yourself. ```javascript import { convertNumberToWords } from '@brazilian-utils/brazilian-utils'; @@ -849,25 +940,24 @@ convertNumberToWords(1001); // "mil e um" convertNumberToWords(2000000); // "dois milhões" convertNumberToWords(-42); // "menos quarenta e dois" convertNumberToWords(2, { gender: 'feminine' }); // "duas" -convertNumberToWords(3, { case: 'upper' }); // "TRÊS" +convertNumberToWords(12.9); // "doze" (truncated toward zero) convertNumberToWords(NaN); // "" ``` ## convertCurrencyToWords -Formats a monetary amount in Brazilian Reais as its "por extenso" textual representation, the style used to write out the amount by hand on cheques and contracts, e.g. `1523.45` becomes `"mil, quinhentos e vinte e três reais e quarenta e cinco centavos"`. `value` is truncated (not rounded) to 2 decimal places. The singular noun is used for exactly 1 ("um real", "um centavo") and "de" is inserted before "reais" when the amount is a round million, billion or trillion of reais. An amount that truncates to nothing becomes `"zero reais"` with no "menos" prefix, any other negative amount is prefixed with "menos", and invalid input returns `""`. Above `Number.MAX_SAFE_INTEGER / 100` reais (about 90 trillion) a double cannot carry cents, so the amount is read as a whole number of reais. `options.case` (part of `ConvertCurrencyToWordsOptions`) sets the letter case of the result: `"lower"` (default), `"sentence"` (capitalizes only the first letter) or `"upper"` (uppercases everything, keeping accents). An invalid `case` value is ignored and `"lower"` is used. +Formats a monetary amount in Brazilian Reais as its "por extenso" textual representation, the style used to write out the amount by hand on cheques and contracts, e.g. `1523.45` becomes `"mil quinhentos e vinte e três reais e quarenta e cinco centavos"`. `value` is truncated (not rounded) to 2 decimal places. The singular noun is used for exactly 1 ("um real", "um centavo") and "de" is inserted before "reais" when the amount is a round million, billion or trillion of reais. An amount that truncates to nothing becomes `"zero reais"` with no "menos" prefix, any other negative amount is prefixed with "menos", and invalid input returns `""`. Above `Number.MAX_SAFE_INTEGER / 100` reais (about 90 trillion) a double cannot carry cents, so the amount is read as a whole number of reais. It takes no options: the result is always lowercase; apply any other casing to it yourself. ```javascript import { convertCurrencyToWords } from '@brazilian-utils/brazilian-utils'; -convertCurrencyToWords(1523.45); // "mil, quinhentos e vinte e três reais e quarenta e cinco centavos" +convertCurrencyToWords(1523.45); // "mil quinhentos e vinte e três reais e quarenta e cinco centavos" convertCurrencyToWords(1); // "um real" convertCurrencyToWords(0.01); // "um centavo" convertCurrencyToWords(1000000); // "um milhão de reais" convertCurrencyToWords(0); // "zero reais" convertCurrencyToWords(-5.5); // "menos cinco reais e cinquenta centavos" convertCurrencyToWords(-0.001); // "zero reais" (truncates to nothing) -convertCurrencyToWords(1000, { case: 'upper' }); // "MIL REAIS" ``` ## getStates @@ -911,7 +1001,7 @@ getStates(); ## getStateByIbgeCode -Get the Brazilian state whose 2-digit IBGE code ("cUF", the Código da Unidade da Federação) matches the given value. This is the same 2-digit UF code found in the first field of every DF-e access key (chave de acesso) issued for NF-e, NFC-e, CT-e and MDF-e documents. Accepts a string or a non-negative integer number, stripping any non-digit characters before matching. Exports the `State` type. +Get the Brazilian state whose 2-digit IBGE code ("cUF", the Código da Unidade da Federação) matches the given value. This is the same 2-digit UF code found in the first field of every DF-e access key (chave de acesso) issued for any of the models `isValidNfeKey` covers: NF-e (55), NFC-e (65), CT-e (57), MDF-e (58), CT-e OS (67), GTV-e (64), BP-e (63), NF3e (66) and NFCom (62). Accepts a string or a non-negative integer number, stripping any non-digit characters before matching. Exports the `State` type. ```javascript import { getStateByIbgeCode } from '@brazilian-utils/brazilian-utils'; @@ -929,7 +1019,7 @@ getStateByIbgeCode(3.5); // null ## getStateCodeByName -Get the two-letter code (sigla) of a Brazilian state given its full name. The match is accent-insensitive, case-insensitive and ignores leading/trailing whitespace, so `'sao paulo'`, `'SÃO PAULO'` and `' São Paulo '` all resolve to `'SP'`. Exports the `StateCode` type. +Get the two-letter code (sigla) of a Brazilian state given its full name. The match is accent-insensitive, case-insensitive and ignores leading/trailing whitespace, so `'sao paulo'`, `'SÃO PAULO'` and `' São Paulo '` all resolve to `'SP'`. Every run of internal whitespace collapses into a single space too, so `'Rio de Janeiro'` resolves to `'RJ'`, while a name written without the space matches nothing (`'saopaulo'` is not `'São Paulo'`). Exports the `StateCode` type. ```javascript import { getStateCodeByName } from '@brazilian-utils/brazilian-utils'; @@ -969,7 +1059,7 @@ getTimezoneByState('ZZ'); // null ## getCities -Get Brazilian cities. Returns all cities if no state is provided, or cities from a specific state. Each call returns a fresh array, so mutating the result never affects subsequent calls. An unknown state code (or a non-`StateCode` value) returns an empty array instead of throwing. +Get Brazilian cities. **Deprecated:** use `getMunicipalities` instead. Returns all cities if no state is provided, or cities from a specific state. Each call returns a fresh array, so mutating the result never affects subsequent calls. An unknown state code (or a non-`StateCode` value) returns an empty array instead of throwing, except for a falsy one: `getCities(null)` and `getCities('')` are read as "no state given" and return every city, where the stricter `getMunicipalities` returns `[]` for them. The state code is matched exactly, case included: `getCities('sp')` returns `[]` where `getCities('SP')` returns the 645 São Paulo cities. `getCities` and `getMunicipalities` are the only state-taking lookups that are case-sensitive; `getStateNameByCode`, `getTimezoneByState`, `getAreaCodesByState` and `getMunicipality` all fold case. ```javascript import { getCities } from '@brazilian-utils/brazilian-utils'; @@ -1007,11 +1097,22 @@ getCities('SP'); // ] ``` -`getCities` embeds all 5571 IBGE municipality names (~153 KB minified, ~49 KB gzipped) and is one of the few heavy exceptions in this otherwise tree-shakeable package. See [Bundle size](getting-started.md#bundle-size) for how to lazy-load it via `@brazilian-utils/brazilian-utils/get-cities` instead of the root import. +`getCities` embeds all 5571 IBGE municipality names (~154.2 KB minified, ~49.8 KB gzipped) and is one of the few heavy exceptions in this otherwise tree-shakeable package. See [Bundle size](getting-started.md#bundle-size) for how to lazy-load it via `@brazilian-utils/brazilian-utils/get-cities` instead of the root import. ## getHolidays -Get Brazilian holidays for a given year. Returns national holidays and optionally state-specific holidays. Each holiday (typed as `Holiday`) has a `type` field (`HolidayType`: `"national"`, `"state"`, `"optional"` or `"religious"`). "Dia da Consciência Negra" (Nov 20) is a national holiday from 2024 onward (Lei nº 14.759/2023). Before that, MT and RJ still carry their own state-level entry named `"Consciência Negra"` on the same date. Results are memoized per `year`/`stateCode`, but each call still returns a fresh copy. An unknown/invalid `stateCode` is ignored, returning national holidays only. +Get Brazilian holidays for a given year. Returns national holidays and optionally state-specific holidays. Each holiday (typed as `Holiday`) has a `type` field (`HolidayType`: `"national"`, `"state"`, `"optional"` or `"religious"`). "Dia da Consciência Negra" (Nov 20) is a national holiday from 2024 onward (Lei nº 14.759/2023). Before that, several states still carry a state-level entry of their own on the same date: `"Consciência Negra"` in MT and RJ, `"Dia Estadual da Consciência Negra"` in AP and `"Dia da Consciência Negra"` in AM and SP. Results are memoized per `year`/`stateCode`, but each call still returns a fresh copy. An unknown/invalid `stateCode` is ignored, returning national holidays only; the lookup reads own properties only, so `"__proto__"`, `"constructor"` and the like are unknown state codes rather than a crash. Only the years 1900 through 2099 are supported, the range the business day utilities inherit; a year outside it returns `[]`. + +Only one state holiday per UF is a feriado civil under [Lei nº 9.093/1995](https://www.planalto.gov.br/ccivil_03/leis/l9093.htm), art. 1º, II, which authorises "a data magna do Estado fixada em lei estadual" in the singular; the other entries rest on ordinary state laws and are reported because they are observed in practice. Notable per-state rules: + +- **SC** — [Lei SC nº 18.531/2022](http://leis.alesc.sc.gov.br/html/2022/18531_2022_lei.html) moves both state holidays, "Dia do Estado de Santa Catarina" (Aug 11) and "Dia de Santa Catarina de Alexandria" (Nov 25), to the following Sunday whenever they fall Monday to Friday, so Monday Aug 11 2025 is a business day in SC and the holiday lands on Sunday Aug 17. The two dates did not start transferring together. Aug 11 transfers from 2005 on, the year [Lei SC nº 13.408/2005](http://leis.alesc.sc.gov.br/html/2005/13408_2005_lei.html) extended the clause to it (published and in force on Jul 15 2005), and stays on Aug 11 before that. Nov 25 transfers from 1999 on, the year [Lei SC nº 11.213/1999](http://leis.alesc.sc.gov.br/html/1999/11213_1999_lei.html) first introduced the clause (published and in force on Nov 12 1999, thirteen days before that year's Nov 25), with a one-year gap: art. 3º of [Lei SC nº 12.906/2004](http://leis.alesc.sc.gov.br/html/2004/12906_2004_lei.html) revoked that law without restating the clause, so Nov 25 2004 alone stays on the statutory date until Lei SC nº 13.408/2005 reinstated the transfer. So Nov 25 1999 (a Thursday) lands on Sunday Nov 28, Nov 25 2002 (a Monday) on Sunday Dec 1, Nov 25 2004 (a Thursday) stays put, and Nov 25 2005 (a Friday) lands on Sunday Nov 27. +- **DF** — [Lei distrital nº 72/1989](https://www.sinj.df.gov.br/sinj/Norma/18459/Lei_72_27_12_1989.html), art. 1º parágrafo único, declares Corpus Christi a feriado. With `stateCode: 'DF'` the single Corpus Christi entry comes back typed `"state"` instead of `"optional"`; it is replaced, not duplicated. +- **GO** — [Lei GO nº 20.756/2020](https://legisla.casacivil.go.gov.br/pesquisa_legislacao/100979/lei-20756), art. 269, II, lists three feriados estaduais: Jul 26 (Fundação da Cidade de Goiás), Oct 24 (Lançamento da Pedra Fundamental de Goiânia) and Oct 28 (Dia do Servidor Público). +- **AL** — Sep 16 is a feriado estadual from 2024 ([Lei AL nº 9.358/2024](https://sapl.al.al.leg.br/norma/3117)) and only a ponto facultativo (`"optional"`) before that. +- **PB** — Jul 26 ("Morte de João Pessoa") is emitted up to 2015 only: [Lei PB nº 10.601/2015](https://sapl.al.pb.leg.br/norma/11988), art. 2º, revoked its basis. +- **TO** — Mar 18 ("Autonomia do Estado do Tocantins") is emitted up to 2008 only: [Lei TO nº 2.013/2009](https://www.al.to.leg.br/arquivo/15724) rewrote the clause that declared the feriado into a commemorative provision. + +The statutory date is what is returned. SC's shift above is the only observance shift modelled; Acre's Tuesday-to-Thursday shift and the Goiás decrees that may move Jul 26 and Oct 28 are not. ```javascript import { getHolidays } from '@brazilian-utils/brazilian-utils'; @@ -1034,7 +1135,7 @@ getHolidays({ year: 2024, stateCode: 'SP' }); ## isValidPassport -Check if a Brazilian passport number is valid (2 letters followed by 6 digits). Accepts both `string` and `number` input; the input is case-insensitive and any non-alphanumeric characters (spaces, dots, hyphens) are ignored. +Check if a Brazilian passport number is valid (2 letters followed by 6 digits). Accepts both `string` and `number` input; the input is case-insensitive and any non-alphanumeric characters (spaces, dots, hyphens) are ignored. A number is accepted for symmetry with `formatPassport`/`parsePassport` but is never valid: the decimal form of a number never starts with the two letters a passport number needs. ```javascript import { isValidPassport } from '@brazilian-utils/brazilian-utils'; @@ -1058,7 +1159,7 @@ formatPassport('AB-123.456'); // 'AB123456' ## generatePassport -Generate a random valid Brazilian passport number. +Generate a random valid Brazilian passport number. Uses `Math.random()` internally, so it is not cryptographically secure. ```javascript import { generatePassport } from '@brazilian-utils/brazilian-utils'; @@ -1079,7 +1180,7 @@ parsePassport(' AB 123 456 '); // 'AB123456' ## generateCep -Generate a random CEP. +Generate a random CEP. Uses `Math.random()` internally, so it is not cryptographically secure. ```javascript import { generateCep } from '@brazilian-utils/brazilian-utils'; @@ -1089,7 +1190,7 @@ generateCep(); // '92500000' ## formatCnh -Format CNH. +Format CNH. `options.pad` (part of `FormatCnhOptions`) left-pads the value with zeros to the full 11 digits before masking (default `false`). ```javascript import { formatCnh } from '@brazilian-utils/brazilian-utils'; @@ -1100,17 +1201,19 @@ formatCnh('2650306461', { pad: true }); // 026503064-61 ## isValidCnh -Check if CNH is valid. +Check if CNH is valid. Spaces, dots and hyphens around/between the digits are ignored, but any other character, a letter in particular, makes the value invalid. A value whose 11 digits are all the same is rejected before the check digits are computed, so `'11111111111'` is invalid. ```javascript import { isValidCnh } from '@brazilian-utils/brazilian-utils'; isValidCnh('00000000119'); // true +isValidCnh('000000001-19'); // true (hyphen before the check digits) +isValidCnh('ab00000000119'); // false (letters are rejected) ``` ## generateCnh -Generate a valid random CNH. +Generate a valid random CNH. Uses `Math.random()` internally, so it is not cryptographically secure. ```javascript import { generateCnh } from '@brazilian-utils/brazilian-utils'; @@ -1120,7 +1223,7 @@ generateCnh(); // '02650306461' ## parseCnh -Remove CNH formatting, keep only digits, and cap the result to 11 digits. +Remove CNH formatting, keep only digits, and cap the result to 11 digits. Returns `''` when there is no digit at all. ```javascript import { parseCnh } from '@brazilian-utils/brazilian-utils'; @@ -1130,49 +1233,60 @@ parseCnh('026503064-61'); // '02650306461' ## getCepInfoByAddress -Fetch CEPs from an address using ViaCEP. Throws `GetCepInfoByAddressValidationError` when the UF, city or street is missing/invalid, `GetCepInfoByAddressNotFoundError` when no address matches the query, and `GetCepInfoByAddressError` when ViaCEP itself answers with an HTTP error status. A request that cannot be performed at all (a transport failure) rejects with the underlying `fetch` error instead. +Fetch CEPs from an address using ViaCEP. Throws `GetCepInfoByAddressValidationError` when the UF, city or street is missing/invalid — including when the argument is not an object at all (omitted, `null`, a string) and when `federalUnit` is not a string, neither of which leaks a raw `TypeError` — `GetCepInfoByAddressNotFoundError` when no address matches the query, and `GetCepInfoByAddressError` when ViaCEP itself answers with an HTTP error status. A request that cannot be performed at all (a transport failure) rejects with the underlying `fetch` error instead. Each item is typed as `CepAddressInfo` and carries the ViaCEP payload unchanged, under ViaCEP's own field names: `cep`, `logradouro`, `complemento`, `unidade`, `bairro`, `localidade`, `uf`, `estado`, `regiao`, `ibge`, `gia`, `ddd` and `siafi`. A broad street name matches many CEPs, so query as narrowly as the address allows. ```javascript import { getCepInfoByAddress } from '@brazilian-utils/brazilian-utils'; const ceps = await getCepInfoByAddress({ - federalUnit: 'SP', - city: 'Sao Paulo', - street: 'Avenida Paulista' + federalUnit: 'MG', + city: 'Ouro Preto', + street: 'Rua Direita' }); // [ // { -// cep: '01310100', -// logradouro: 'Avenida Paulista', -// complemento: 'lado par', -// bairro: 'Bela Vista', -// localidade: 'São Paulo', -// uf: 'SP' +// cep: '35411-152', +// logradouro: 'Rua Direita', +// complemento: '', +// unidade: '', +// bairro: 'Riacho (Amarantina)', +// localidade: 'Ouro Preto', +// uf: 'MG', +// estado: 'Minas Gerais', +// regiao: 'Sudeste', +// ibge: '3146107', +// gia: '', +// ddd: '31', +// siafi: '4921' // } // ] ``` ## generateProcessoJuridico -Generate a valid random processo jurídico number according to [CNJ's definition](https://atos.cnj.jus.br/atos/detalhar/119). `year` must be between the current year and 9999, `court` between 1 and 9; out-of-range values return `null`. Uses `Math.random()` internally, so it is not cryptographically secure. +Generate a valid random processo jurídico number according to [CNJ's definition](https://atos.cnj.jus.br/atos/detalhar/119). `year` must be between the current year and 9999, `court` between 1 and 9; out-of-range values return `null`. The órgão (`J`) and the tribunal (`TR`) are drawn from the closed lists of art. 1º, § 4º and § 5º, so the pair always names a court that exists: `court` picks the órgão and the `TR` is drawn among the tribunais that órgão has. The unidade de origem (`OOOO`) is drawn freely, since the resolution publishes no central list for it. Uses `Math.random()` internally, so it is not cryptographically secure. ```javascript import { generateProcessoJuridico } from '@brazilian-utils/brazilian-utils'; -generateProcessoJuridico(); // '89478643020269670326' -generateProcessoJuridico({ year: 2026, court: 5 }); // string | null +generateProcessoJuridico(); // '89478645020266070326' +generateProcessoJuridico({ year: 2026, court: 5 }); // '98412562120265087260' (Justiça do Trabalho, TRT da 8ª Região) generateProcessoJuridico({ year: 10000 }); // null (year out of range) +generateProcessoJuridico({ court: 10 }); // null (no such órgão) ``` ## formatLegalNature -Format a legal nature code. +Format a legal nature code. `options.pad` (part of `FormatLegalNatureOptions`) works exactly like it does in `formatCpf`/`formatCep`: with the default `false` the mask is applied progressively, as far as the value goes; with `true` the value is first left padded with zeros to the 4 digits of a complete code. Use `isValidLegalNature` to check a code. ```javascript import { formatLegalNature } from '@brazilian-utils/brazilian-utils'; formatLegalNature('2062'); // 206-2 +formatLegalNature(2062); // 206-2 +formatLegalNature('206'); // 206 (masked as far as it goes) +formatLegalNature('62', { pad: true }); // 006-2 (padded to 4 digits first) ``` ## isValidLegalNature @@ -1188,7 +1302,7 @@ isValidLegalNature('9999'); // false ## generateLegalNature -Generate a random valid legal nature code. +Generate a random valid legal nature code. Uses `Math.random()` internally, so it is not cryptographically secure. ```javascript import { generateLegalNature } from '@brazilian-utils/brazilian-utils'; @@ -1218,20 +1332,45 @@ const legalNatures = getLegalNatures(); legalNatures['2062']; // 'Sociedade Empresária Limitada' ``` +## getLegalNaturesByCategory + +Get every legal nature of a CONCLA category, the group given by the first digit of the code: `1` Administração Pública, `2` Entidades Empresariais, `3` Entidades sem Fins Lucrativos, `4` Pessoas Físicas and `5` Organizações Internacionais e Outras Instituições Extraterritoriais. The category is accepted as a string or as a number, the entries come back sorted by code, and an unknown category gives `[]`. + +```javascript +import { getLegalNaturesByCategory } from '@brazilian-utils/brazilian-utils'; + +getLegalNaturesByCategory('4')[0]; +// { +// code: '4014', +// description: 'Empresa Individual Imobiliária', +// category: { code: '4', description: 'Pessoas Físicas' }, +// } +getLegalNaturesByCategory(4).length; // 6 +getLegalNaturesByCategory('2').length; // 33 +getLegalNaturesByCategory('9'); // [] +``` + ## getLegalNature -Look a legal nature code up in the official IBGE/CONCLA table. +Look a legal nature code up in the official IBGE/CONCLA table. The entry also carries the CONCLA category the code is listed under, taken from its first digit. No legal nature code starts with a zero, that first digit is the category (1 to 5), so nothing is ever padded here: a number and the string of the same digits are read identically. ```javascript import { getLegalNature } from '@brazilian-utils/brazilian-utils'; -getLegalNature('2062'); // { code: '2062', description: 'Sociedade Empresária Limitada' } +getLegalNature('2062'); +// { +// code: '2062', +// description: 'Sociedade Empresária Limitada', +// category: { code: '2', description: 'Entidades Empresariais' }, +// } +getLegalNature('206-2')?.code; // '2062' +getLegalNature(206.2)?.category.description; // 'Entidades Empresariais' getLegalNature('0000'); // null ``` ## generatePhone -Generate a random Brazilian phone number. Accepts `'mobile'`, `'landline'` or `'service'` (typed as `GeneratePhoneType`); a service number has no DDD. Omitted, it randomly generates a mobile or a landline, never a service number. +Generate a random Brazilian phone number. Accepts `'mobile'`, `'landline'` or `'service'` (typed as `GeneratePhoneType`); a service number has no DDD. Omitted, it randomly generates a mobile or a landline, never a service number. A generated mobile number always starts with 9, so it passes both `isValidMobilePhone` numbering rules. ```javascript import { generatePhone } from '@brazilian-utils/brazilian-utils'; @@ -1244,7 +1383,7 @@ generatePhone('service'); // '08001234567' or '40041234' ## formatLicensePlate -Format a license plate. Old Brazilian plates (`LLLNNNN`) are returned with a hyphen and Mercosul plates (`LLLNLNN`) stay normalized. +Format a license plate. Old Brazilian plates (`LLLNNNN`) are returned with a hyphen and Mercosul plates (`LLLNLNN`) stay normalized. Partial values are formatted as far as they go, so it can also be used as an input mask, and a value that cannot start a valid plate gives `''`. ```javascript import { formatLicensePlate } from '@brazilian-utils/brazilian-utils'; @@ -1255,7 +1394,7 @@ formatLicensePlate('abc1d23'); // 'ABC1D23' ## generateLicensePlate -Generate a random license plate in the chosen format. +Generate a random license plate in the chosen format. Uses `Math.random()` internally, so it is not cryptographically secure. ```javascript import { generateLicensePlate } from '@brazilian-utils/brazilian-utils'; @@ -1264,6 +1403,8 @@ generateLicensePlate(); // 'ABC1D23' (Mercosul, the default) generateLicensePlate('LLLNNNN'); // 'ABC1234' ``` +A `format` string outside the two supported literals is not rejected: it is used verbatim, character by character, with `L` producing a letter and every other position a digit. So `generateLicensePlate('LLLNNLN')` returns a plate in the withdrawn motorcycle sequence, which `isValidLicensePlate` rejects; `generateLicensePlate('bogus')` returns five digits; and `generateLicensePlate('')` returns an empty string. Only a non-string falls back to the Mercosul default. This is the 2.3.0 behaviour, kept for the JavaScript callers the TypeScript type cannot reach. + ## getFormatLicensePlate Detect the normalized format of a license plate. @@ -1304,7 +1445,7 @@ convertLicensePlateToMercosul('ABC1D23'); // '' (already Mercosul) ## generatePis -Generate a valid random PIS. +Generate a valid random PIS. Uses `Math.random()` internally, so it is not cryptographically secure. ```javascript import { generatePis } from '@brazilian-utils/brazilian-utils'; @@ -1314,7 +1455,7 @@ generatePis(); // '91077906857' ## getMunicipality -Get municipality information by IBGE code, or get an IBGE code from municipality name and UF. A single function handles both directions, based on whether `options` has a `code` or a `municipalityName`/`uf`. `code` accepts both `string` and `number` input and must be exactly 7 digits, otherwise the function resolves to `null`. A `code` given as a number must be a non-negative integer: a sign and a decimal point are not digits, so `-3550308` and `355030.8` resolve to `null` instead of being read as `3550308`. Resolution is entirely offline, from a bundled IBGE dataset: no network request is made. The municipality name match ignores accents and casing. An unknown municipality, an unknown UF or invalid input all resolve to `null`. +Get municipality information by IBGE code, or get an IBGE code from municipality name and UF. **Deprecated:** use `getMunicipalityByCode` instead, which is synchronous and offline; matching a municipality by name is up to the application, over `getMunicipalities`. A single function handles both directions, based on whether `options` has a `code` or a `municipalityName`/`uf`. `code` accepts both `string` and `number` input and must be exactly 7 digits, otherwise the function resolves to `null`. A `code` given as a number must be a non-negative integer: a sign and a decimal point are not digits, so `-3550308` and `355030.8` resolve to `null` instead of being read as `3550308`. Resolution is entirely offline, from a bundled IBGE dataset: no network request is made. The municipality name match ignores accents and casing, and every run of whitespace collapses into a single space, so `'sao paulo'` matches `'São Paulo'` while a name written without the space does not; the casing is folded to upper case, the direction Unicode expands `'ß'` to `'SS'` in, so `'Paßos'` matches `'Passos'`. An unknown municipality, an unknown UF or invalid input all resolve to `null`. The `[name, uf]` pair is a fresh array on every call, so mutating the result never affects subsequent lookups. ```javascript import { getMunicipality } from '@brazilian-utils/brazilian-utils'; @@ -1335,9 +1476,32 @@ await getMunicipality({ code: '123' }); // null (not 7 digits) ``` +In TypeScript the return type follows the direction of the lookup: a `{ code }` query resolves to `[string, string] | null`, a `{ municipalityName, uf }` query resolves to `string | null`, and a query whose direction is only known at run time (a variable typed as `GetMunicipalityOptions`) resolves to the union of both. + +```typescript +import { + getMunicipality, + type GetMunicipalityByCodeOptions, + type GetMunicipalityByNameOptions, + type GetMunicipalityOptions, +} from '@brazilian-utils/brazilian-utils'; + +const byCode: GetMunicipalityByCodeOptions = { code: '3550308' }; +const byName: GetMunicipalityByNameOptions = { municipalityName: 'sao paulo', uf: 'sp' }; + +await getMunicipality(byCode); +// Promise<[string, string] | null> + +await getMunicipality(byName); +// Promise + +const lookUp = (options: GetMunicipalityOptions) => getMunicipality(options); +// (options: GetMunicipalityOptions) => Promise<[string, string] | string | null> +``` + ## getMunicipalities -Get Brazilian municipalities published by the IBGE. Returns all municipalities if no state is provided, or municipalities from a specific state. Each municipality is returned as `{ code, name, stateCode }`, where `code` is the 7-digit IBGE municipality code. Results are sorted by name with `localeCompare` in the "pt-BR" locale. Each call returns a fresh array of fresh objects, so mutating the result never affects subsequent calls. An unknown state code returns an empty array instead of throwing. +Get Brazilian municipalities published by the IBGE. Returns all municipalities if no state is provided, or municipalities from a specific state. Each municipality is returned as `{ code, name, stateCode }`, where `code` is the 7-digit IBGE municipality code. Results are sorted by name with `localeCompare` in the "pt-BR" locale. Each call returns a fresh array of fresh objects, so mutating the result never affects subsequent calls. An unknown state code returns an empty array instead of throwing. Only an omitted (or `undefined`) `stateCode` asks for the full list: `getMunicipalities(null)` and `getMunicipalities('')` return `[]`, where the looser `getCities(null)` and `getCities('')` return every city. The state code is matched exactly, case included: `getMunicipalities('sp')` returns `[]` where `getMunicipalities('SP')` returns the 645 São Paulo municipalities. `getMunicipalities` and `getCities` are the only state-taking lookups that are case-sensitive; `getStateNameByCode`, `getTimezoneByState`, `getAreaCodesByState` and `getMunicipality` all fold case. ```javascript import { getMunicipalities } from '@brazilian-utils/brazilian-utils'; @@ -1385,10 +1549,9 @@ getMunicipalityByCode(3550308); getMunicipalityByCode('0000000'); // null (unknown code) getMunicipalityByCode('123'); // null (not 7 digits) ``` - ## isHoliday -Check if a specific date is a Brazilian holiday. The check compares `targetDate`'s local calendar date (year/month/day as read locally), not its underlying UTC instant. Returns `false` when `targetDate` is missing or not a valid `Date`. +Check if a specific date is a Brazilian holiday. The check compares `targetDate`'s local calendar date (year/month/day as read locally), not its underlying UTC instant. Returns `false` when `targetDate` is missing or not a valid `Date`. An invalid `stateCode` is treated in two different ways: a string that is not a known state code is ignored and only national holidays are considered, the same as `getHolidays`, while a `stateCode` that is present and is not a string at all (a number, `null`, an object) is rejected and makes the call return `false` even for a national holiday. ```javascript import { isHoliday } from '@brazilian-utils/brazilian-utils'; @@ -1400,7 +1563,7 @@ isHoliday(); // false ## isBusinessDay -Check if a date is a Brazilian business day (dia útil). Returns `false` for Saturdays, Sundays, and Brazilian holidays returned by `getHolidays` for `value`'s local calendar day (year/month/day as read locally), the same convention used by `isHoliday`. `options.includeOptional` (part of `IsBusinessDayOptions`) defaults to `true`, so optional-type holidays (`Holiday.type === "optional"`, i.e. Carnaval and Corpus Christi) also count as non-business days; pass `false` to only treat statutory holidays this way. `options.stateCode` also considers that state's holidays; an unknown/invalid `stateCode` is ignored, falling back to national holidays only. A `value` that is not a valid `Date` returns `false`. Only years from 1900 through 2099 are supported, the range `getHolidays` computes; a date outside it returns `false`. +Check if a date is a Brazilian business day (dia útil). Returns `false` for Saturdays, Sundays, and Brazilian holidays returned by `getHolidays` for `value`'s local calendar day (year/month/day as read locally), the same convention used by `isHoliday`. `options.includeOptional` (part of `BusinessDayOptions`, the option type every business day utility shares) defaults to `true`, so optional-type holidays (`Holiday.type === "optional"`, i.e. Carnaval and Corpus Christi) also count as non-business days; pass `false` to only treat statutory holidays this way. `options.stateCode` also considers that state's holidays; a string that is not a known state code is ignored, falling back to national holidays only, while a `stateCode` that is present and is not a string at all (a number, `null`, an object) is rejected and makes the call return `false` even for an ordinary weekday, the same split `isHoliday` makes and the value `addBusinessDays`, `subBusinessDays` and `differenceInBusinessDays` reject with `null`. A `value` that is not a valid `Date` returns `false`. Only years from 1900 through 2099 are supported, the range `getHolidays` computes; a date outside it returns `false`. ```javascript import { isBusinessDay } from '@brazilian-utils/brazilian-utils'; @@ -1417,38 +1580,55 @@ isBusinessDay(new Date('not a date')); // false ## addBusinessDays -Add a number of Brazilian business days (dias úteis) to a date, skipping Saturdays, Sundays and Brazilian holidays exactly as `isBusinessDay` defines them (same `stateCode`/`includeOptional` options). Returns a new `Date`; the input `date` (part of `AddBusinessDaysParams`) is never mutated, and its time-of-day is preserved in the result. `days: 0` returns a new `Date` equal to `date`, unchanged, even when `date` itself falls on a weekend or holiday, this mirrors the verified behavior of [date-fns' `addBusinessDays(date, 0)`](https://date-fns.org/docs/addBusinessDays), which also does not roll the input to the next business day. A negative `days` walks backwards, one business day at a time, also like date-fns. Returns `null` on bad input: a `date` that is not a valid `Date`, a `days` that is not a finite integer, or a `stateCode` that is not a string. Only years from 1900 through 2099 are supported, the range `getHolidays` computes; a date outside it (or, for `addBusinessDays`, a walk that leaves it) returns `null`. +Add a number of Brazilian business days (dias úteis) to a date, skipping Saturdays, Sundays and Brazilian holidays exactly as `isBusinessDay` defines them (same `BusinessDayOptions`: `options.includeOptional`, default `true`, and `options.stateCode` work exactly as they do there). The signature is date-fns': `addBusinessDays(date, amount, options?)`. Returns a new `Date`; the input `date` is never mutated, and its time-of-day is preserved in the result. An `amount` of `0` returns a new `Date` equal to `date`, unchanged, even when `date` itself falls on a weekend or holiday, this mirrors the verified behavior of [date-fns' `addBusinessDays(date, 0)`](https://date-fns.org/docs/addBusinessDays), which also does not roll the input to the next business day. A negative `amount` walks backwards, one business day at a time, also like date-fns. Returns `null` on bad input: a `date` that is not a valid `Date`, an `amount` that is not a finite integer, or a `stateCode` that is not a string; an `options` that is not an object at all is ignored, exactly as `isBusinessDay` ignores it. Only years from 1900 through 2099 are supported, the range `getHolidays` computes; a date outside it, or a walk that leaves it, returns `null`. ```javascript import { addBusinessDays } from '@brazilian-utils/brazilian-utils'; -addBusinessDays({ date: new Date(2024, 0, 2, 12), days: 1 }); // Date, 2024-01-03 12:00 (next day is already a business day) -addBusinessDays({ date: new Date(2024, 11, 31, 12), days: 1 }); // Date, 2025-01-02 12:00 (2025-01-01 is Ano novo, skipped) -addBusinessDays({ date: new Date(2024, 0, 5, 12), days: -1 }); // Date, 2024-01-04 12:00 (walks backwards) -addBusinessDays({ date: new Date(2024, 0, 6, 12), days: 0 }); // Date, 2024-01-06 12:00 (unchanged, even though Saturday is not a business day) -addBusinessDays({ date: new Date(2024, 6, 8, 12), days: 1, stateCode: 'SP' }); // Date, 2024-07-10 12:00 (2024-07-09 is Revolução Constitucionalista in SP, skipped) -addBusinessDays({ date: new Date('not a date'), days: 1 }); // null -addBusinessDays({ date: new Date(2024, 0, 2), days: 1.5 }); // null (not an integer) +addBusinessDays(new Date(2024, 0, 2, 12), 1); // Date, 2024-01-03 12:00 (next day is already a business day) +addBusinessDays(new Date(2024, 11, 31, 12), 1); // Date, 2025-01-02 12:00 (2025-01-01 is Ano novo, skipped) +addBusinessDays(new Date(2024, 0, 5, 12), -1); // Date, 2024-01-04 12:00 (walks backwards) +addBusinessDays(new Date(2024, 0, 6, 12), 0); // Date, 2024-01-06 12:00 (unchanged, even though Saturday is not a business day) +addBusinessDays(new Date(2024, 6, 8, 12), 1, { stateCode: 'SP' }); // Date, 2024-07-10 12:00 (2024-07-09 is Revolução Constitucionalista in SP, skipped) +addBusinessDays(new Date('not a date'), 1); // null +addBusinessDays(new Date(2024, 0, 2), 1.5); // null (not an integer) +``` + +## subBusinessDays + +Subtract a number of Brazilian business days (dias úteis) from a date: `subBusinessDays(date, amount, options?)` is `addBusinessDays(date, -amount, options)`, which is exactly how it is implemented, so every detail above (the preserved time-of-day, the untouched input, an `amount` of `0` returning the date unchanged, the 1900-2099 range and the `null` cases) holds here too, `options.stateCode` and `options.includeOptional` included. A negative `amount` walks forwards. + +```javascript +import { subBusinessDays } from '@brazilian-utils/brazilian-utils'; + +subBusinessDays(new Date(2024, 0, 5, 12), 1); // Date, 2024-01-04 12:00 (previous day is already a business day) +subBusinessDays(new Date(2024, 0, 8, 12), 1); // Date, 2024-01-05 12:00 (walks back over the weekend) +subBusinessDays(new Date(2025, 0, 2, 12), 1); // Date, 2024-12-31 12:00 (2025-01-01 is Ano novo, skipped) +subBusinessDays(new Date(2024, 0, 5, 12), -1); // Date, 2024-01-08 12:00 (walks forwards) +subBusinessDays(new Date(2024, 0, 6, 12), 0); // Date, 2024-01-06 12:00 (unchanged, even though Saturday is not a business day) +subBusinessDays(new Date(2024, 6, 10, 12), 1, { stateCode: 'SP' }); // Date, 2024-07-08 12:00 (2024-07-09 is Revolução Constitucionalista in SP, skipped) +subBusinessDays(new Date('not a date'), 1); // null +subBusinessDays(new Date(2024, 0, 2), 1.5); // null (not an integer) ``` ## differenceInBusinessDays -Count the number of Brazilian business days (dias úteis) between two dates, mirroring the semantics of [date-fns' `differenceInBusinessDays`](https://date-fns.org/docs/differenceInBusinessDays) (verified against its source): `params.from` is counted when it is itself a business day, `params.to` is never counted, and every business day strictly in between is counted once. Only the calendar day of each `Date` matters, the time of day is ignored. Business days are determined exactly like `isBusinessDay` (same `stateCode`/`includeOptional` options). `from`/`to` on the same calendar day return `0`; a `to` before `from` returns a negative number. Returns `null` on bad input: a `from`/`to` that is not a valid `Date`, or a `stateCode` that is not a string. Parameters are typed as `DifferenceInBusinessDaysParams`. Only years from 1900 through 2099 are supported, the range `getHolidays` computes; a date outside it (or, for `addBusinessDays`, a walk that leaves it) returns `null`. +Count the number of Brazilian business days (dias úteis) between two dates, mirroring the semantics of [date-fns' `differenceInBusinessDays`](https://date-fns.org/docs/differenceInBusinessDays) (verified against its source), argument order included: `differenceInBusinessDays(laterDate, earlierDate, options?)`. The walk starts at `earlierDate` and stops just before `laterDate`, so `earlierDate` is counted when it is itself a business day, `laterDate` is never counted, and every business day strictly in between is counted once. Only the calendar day of each `Date` matters, the time of day is ignored. Business days are determined exactly like `isBusinessDay` (same `BusinessDayOptions`), `options.includeOptional` (default `true`) and `options.stateCode` included. The result is positive when `laterDate` is after `earlierDate` and negative when it is before it; two dates on the same calendar day return `0`. Returns `null` on bad input: a date that is not a valid `Date`, or a `stateCode` that is not a string; an `options` that is not an object at all is ignored. Only years from 1900 through 2099 are supported, the range `getHolidays` computes; a date outside it returns `null`. ```javascript import { differenceInBusinessDays } from '@brazilian-utils/brazilian-utils'; -differenceInBusinessDays({ from: new Date(2024, 0, 1), to: new Date(2024, 0, 2) }); // 0 (Jan 1 is Ano novo) -differenceInBusinessDays({ from: new Date(2024, 0, 2), to: new Date(2024, 0, 3) }); // 1 (Jan 2 counted, a Tuesday) -differenceInBusinessDays({ from: new Date(2024, 0, 3), to: new Date(2024, 0, 2) }); // -1 (to before from) -differenceInBusinessDays({ from: new Date(2024, 0, 2), to: new Date(2024, 0, 2) }); // 0 (same day) -differenceInBusinessDays({ from: new Date(2024, 6, 8), to: new Date(2024, 6, 10), stateCode: 'SP' }); // 1 (2024-07-09 is a state holiday in SP) -differenceInBusinessDays({ from: new Date('not a date'), to: new Date() }); // null +differenceInBusinessDays(new Date(2024, 0, 2), new Date(2024, 0, 1)); // 0 (Jan 1 is Ano novo, not counted) +differenceInBusinessDays(new Date(2024, 0, 3), new Date(2024, 0, 2)); // 1 (Jan 2 counted, a Tuesday; Jan 3 is not) +differenceInBusinessDays(new Date(2024, 0, 2), new Date(2024, 0, 3)); // -1 (the later date comes first, so the count is negative) +differenceInBusinessDays(new Date(2024, 0, 2), new Date(2024, 0, 2)); // 0 (same day) +differenceInBusinessDays(new Date(2024, 6, 10), new Date(2024, 6, 8), { stateCode: 'SP' }); // 1 (2024-07-09 is a state holiday in SP) +differenceInBusinessDays(new Date(), new Date('not a date')); // null ``` ## convertDateToWords -Formats a date as its Brazilian Portuguese "por extenso" textual representation, e.g. `"01/01/2024"` becomes `"primeiro de janeiro de dois mil e vinte e quatro"`. Accepts a `Date` (read by its local calendar date, the same convention used by `isHoliday`) or a string in `"dd/mm/yyyy"` or ISO `"yyyy-mm-dd"` format. With the default `options.style` of `"full"`, day 1 is written as "primeiro" and every other day uses the cardinal number; with `"month"`, only the month name is spelled out and the day/year are left as digits (day 1 as `"1º"`, e.g. `"2 de março de 2024"`, `"1º de janeiro de 2024"`). Month names are lowercase. In `"full"` style the year is written out as a cardinal number without the thousands comma that `convertNumberToWords`/`convertCurrencyToWords` use (`1999` reads as `"mil novecentos e noventa e nove"`, not `"mil, novecentos e noventa e nove"`), matching how a date is read aloud. `options.weekday` (default `false`) prefixes the pt-BR weekday name in lowercase followed by a comma (`"sábado, dois de março de dois mil e vinte e quatro"`), computed from the resolved calendar date. `options.case` sets the letter case of the whole result: `"lower"` (default), `"sentence"` (capitalizes only the first letter) or `"upper"` (uppercases everything, keeping accents). Invalid `case`/`style` values are ignored and the default is used. February 29th is accepted on the leap years of the proleptic Gregorian calendar (divisible by 4, except centuries not divisible by 400). Returns `""` for an invalid `Date`, a malformed string, a day/month that does not exist, or a date before year 1. +Formats a date as its Brazilian Portuguese "por extenso" textual representation, e.g. `"01/01/2024"` becomes `"primeiro de janeiro de dois mil e vinte e quatro"`. Accepts a `Date` (read by its local calendar date, the same convention used by `isHoliday`) or a string in `"dd/mm/yyyy"` or ISO `"yyyy-mm-dd"` format. With the default `options.style` of `"full"`, day 1 is written as "primeiro" and every other day uses the cardinal number; with `"month"`, only the month name is spelled out and the day/year are left as digits (day 1 as `"1º"`, e.g. `"2 de março de 2024"`, `"1º de janeiro de 2024"`). Month names are lowercase. In `"full"` style the year is written out as a cardinal number without the thousands comma that `convertNumberToWords`/`convertCurrencyToWords` use (`1999` reads as `"mil novecentos e noventa e nove"`, not `"mil novecentos e noventa e nove"`), matching how a date is read aloud. `options.weekday` (default `false`) prefixes the pt-BR weekday name in lowercase followed by a comma (`"sábado, dois de março de dois mil e vinte e quatro"`), computed from the resolved calendar date. An invalid `style` value is ignored and the default is used. The result is always lowercase; apply any other casing to it yourself. February 29th is accepted on the leap years of the proleptic Gregorian calendar (divisible by 4, except centuries not divisible by 400). Returns `""` for an invalid `Date`, a malformed string, a day/month that does not exist, or a date before year 1. ```javascript import { convertDateToWords } from '@brazilian-utils/brazilian-utils'; @@ -1456,7 +1636,6 @@ import { convertDateToWords } from '@brazilian-utils/brazilian-utils'; convertDateToWords('01/01/2024'); // "primeiro de janeiro de dois mil e vinte e quatro" convertDateToWords('2024-01-02'); // "dois de janeiro de dois mil e vinte e quatro" convertDateToWords(new Date(2024, 0, 1)); // "primeiro de janeiro de dois mil e vinte e quatro" -convertDateToWords('01/01/2024', { case: 'sentence' }); // "Primeiro de janeiro de dois mil e vinte e quatro" convertDateToWords('02/03/2024', { style: 'month' }); // "2 de março de 2024" convertDateToWords('01/01/2024', { style: 'month' }); // "1º de janeiro de 2024" convertDateToWords('02/03/2024', { weekday: true }); // "sábado, dois de março de dois mil e vinte e quatro" @@ -1479,7 +1658,7 @@ formatVoterId('1234567880191'); // '1234 5678 8 01 91' (13-digit SP/MG voter id) ## isValidVoterId -Check if a voter ID number is valid. Accepts both the standard 12-digit id and the 13-digit id issued by São Paulo (UF `01`) and Minas Gerais (UF `02`). +Check if a voter ID number is valid. Accepts both the standard 12-digit id and the 13-digit id issued by São Paulo (UF `01`) and Minas Gerais (UF `02`). Whitespace and dots are accepted around and between the `0000 0000 00 00` groups, but any other character, a letter in particular, makes the value invalid. ```javascript import { generateVoterId, isValidVoterId } from '@brazilian-utils/brazilian-utils'; @@ -1514,34 +1693,47 @@ parseVoterId('1234 5678 8 01 91'); // '1234567880191' (13-digit SP/MG voter id) ## isValidCns -Check if a CNS (Cartão Nacional de Saúde) number is valid, the unique SUS (Sistema Único de Saúde) user identifier. Definitive cards (starting with 1 or 2) are validated with the same mod 11 weighting used for PIS numbers over an embedded 11 digit base, adjusting the base by +2 when the raw check digit computes to 10. Provisional cards (starting with 7, 8 or 9) are validated instead by a single weighted sum (weights 15 down to 1) that must be a multiple of 11. The value has to be written as the 15 digits, optionally split into the printed groups of 3-4-4-4 by whitespace or the usual mask characters; letters among the digits are rejected instead of being read past. +Check if a CNS (Cartão Nacional de Saúde) number is valid, the unique SUS (Sistema Único de Saúde) user identifier. Definitive cards (starting with 1 or 2) are validated over an embedded 11 digit PIS/PASEP/NIS derived base weighted 15 down to 5; when the raw digit computes to 10, DATASUS raises the weighted sum by 2, recomputes the digit and marks the card with the suffix `001` instead of `000`. Provisional cards (starting with 7, 8 or 9) are validated instead by a single weighted sum (weights 15 down to 1) that must be a multiple of 11. The value has to be written as the 15 digits, optionally split into the printed groups of 3-4-4-4 by whitespace, `.`, `-` or `/`, the interchangeable mask characters `isValidCpf` and `isValidCnpj` accept, a run of them between two groups included; letters among the digits, or a separator inside a group, are rejected instead of being read past. + +The two routines come from the [ANVISA CNS validation page](https://rni-docs.anvisa.gov.br/docs/regras_gerais/validacoes/validacaoCNS/), which sits behind a bot filter and answers HTTP 403 to non-browser clients. The [e-SUS APS page](https://integracao.esusab.ufsc.br/ledi/documentacao/regras/algoritmo_CNS.html) documents the same algorithm and is reachable without a browser, but applies the provisional routine to numbers starting with 5, 7, 8 or 9; this implementation follows ANVISA and rejects a 5-prefixed number even when its weighted sum checks out. ```javascript import { isValidCns } from '@brazilian-utils/brazilian-utils'; isValidCns('123456789010000'); // true (definitive) isValidCns('700000000000005'); // true (provisional) +isValidCns('123.4567-8901/0000'); // true (any of the mask characters) isValidCns('12345678901'); // false (wrong length) isValidCns('abc123456789010000'); // false (not written as a CNS) ``` ## formatCns -Format a CNS (Cartão Nacional de Saúde) number into the common display groups of 3-4-4-4 digits separated by spaces. Options are typed as `FormatCnsOptions`. +Format a CNS (Cartão Nacional de Saúde) number into the common display groups of 3-4-4-4 digits separated by spaces. `options.pad` (part of `FormatCnsOptions`) left-pads the value with zeros up to the 15 slots of the pattern before masking (default `false`). ```javascript import { formatCns } from '@brazilian-utils/brazilian-utils'; -formatCns('123456789010001'); // '123 4567 8901 0001' -formatCns(123456789010001); // '123 4567 8901 0001' +formatCns('123456789010000'); // '123 4567 8901 0000' +formatCns(123456789010000); // '123 4567 8901 0000' formatCns('89010001', { pad: true }); // '000 0000 8901 0001' ``` +## parseCns + +Remove CNS (Cartão Nacional de Saúde) formatting, keep only digits, and cap the result to 15 digits. A partial value passes through as far as it goes, so it can also strip the mask off an input still being typed; use `isValidCns` to check the number itself. + +```javascript +import { parseCns } from '@brazilian-utils/brazilian-utils'; + +parseCns('123 4567 8901 0000'); // '123456789010000' +``` + ## isValidCertidao -Check if the matrícula of a certidão de registro civil (nascimento, casamento, óbito and the other acts kept by a serventia de registro civil das pessoas naturais) is valid. The matrícula has 32 digits laid out as 6 (CNS da serventia) + 2 (acervo) + 2 (serviço) + 4 (ano) + 1 (tipo do livro) + 5 (livro) + 3 (folha) + 7 (termo) + 2 (dígitos verificadores), and both check digits are modulus 11 with weights cycling from 2 to 10 and back through 0. Accepts the usual mask characters and whitespace between/around groups. The layout is the in-force one of [art. 473 of the Código Nacional de Normas da Corregedoria Nacional de Justiça](https://atos.cnj.jus.br/atos/detalhar/5243) (Provimento CNJ nº 149/2023, in the wording of the Provimento CN nº 182/2024); the matrícula itself was instituted by the now revoked [Provimento CNJ nº 2/2009](https://atos.cnj.jus.br/atos/detalhar/1311). The check digits are detailed by [ghiorzi.org](http://ghiorzi.org/DVnew.htm) and implemented by [validation-br](https://github.com/klawdyo/validation-br/blob/feat-certidao/src/certidao.ts) and [validator-docs](https://github.com/geekcom/validator-docs/blob/master/src/validator-docs/Rules/Certidao.php). +Check if the matrícula of a certidão de registro civil (nascimento, casamento, óbito and the other acts kept by a serventia de registro civil das pessoas naturais) is valid. The matrícula has 32 digits laid out as 6 (CNS da serventia) + 2 (acervo) + 2 (serviço) + 4 (ano) + 1 (tipo do livro) + 5 (livro) + 3 (folha) + 7 (termo) + 2 (dígitos verificadores), and both check digits are modulus 11 with the weights cycling from 2 to 10 and back through 0: the first pass starts at 2 over the 30 base digits, the second at 1 over the 31 digits that include the first check digit, and in both a remainder of 10 is read as 1. Accepts the usual mask characters and whitespace between/around groups. The layout is the one [art. 473 of the Código Nacional de Normas da Corregedoria Nacional de Justiça](https://atos.cnj.jus.br/atos/detalhar/5243) (Provimento CNJ nº 149/2023) currently publishes, with inciso II and §§ 1º and 3º to 5º in the redação of the Provimento CN nº 237/2026 and the rest of the article, § 2º included, in that of the Provimento CN nº 182/2024; the matrícula itself was instituted by the now revoked [Provimento CNJ nº 2/2009](https://atos.cnj.jus.br/atos/detalhar/1311) and got its digit structure from the also revoked [Provimento CNJ nº 3/2009, art. 7º](https://atos.cnj.jus.br/atos/detalhar/1310). The check digits are detailed by [ghiorzi.org](http://ghiorzi.org/DVnew.htm) and implemented by [validation-br](https://github.com/klawdyo/validation-br/blob/feat-certidao/src/certidao.ts) and [validator-docs](https://github.com/geekcom/validator-docs/blob/master/src/validator-docs/Rules/Certidao.php). -The serviço digits are fixed at `55`, the code [art. 473, III](https://atos.cnj.jus.br/atos/detalhar/5243) assigns to the registro civil das pessoas naturais, so a matrícula carrying any other pair in the ninth and tenth positions is rejected however good its check digits are. The book-type digit always has to name one of the nine book types (the same `CertidaoType` returned by `parseCertidao`), so a matrícula whose digit is `0` is rejected however good its check digits are, the same way `parseCertidao` returns `null` for it. `options.accept` (part of `IsValidCertidaoOptions`) narrows that to the listed types; it defaults to every type, and a value that is not an array falls back to that default. Only a string is accepted: the 32 digits of a matrícula are more than a JavaScript number can hold. +The serviço digits are fixed at `55`, the code [art. 473, III](https://atos.cnj.jus.br/atos/detalhar/5243) assigns to the registro civil das pessoas naturais, so a matrícula carrying any other pair in the ninth and tenth positions is rejected however good its check digits are. The book-type digit always has to name one of the nine book types (the same `CertidaoType` returned by `getCertidaoInfo`), so a matrícula whose digit is `0` is rejected however good its check digits are, the same way `getCertidaoInfo` returns `null` for it. `options.accept` (part of `IsValidCertidaoOptions`) narrows that to the listed types; it defaults to every type, and a value that is not an array falls back to that default. Only a string is accepted: the 32 digits of a matrícula are more than a JavaScript number can hold. ```javascript import { isValidCertidao } from '@brazilian-utils/brazilian-utils'; @@ -1555,14 +1747,38 @@ isValidCertidao('104539 01 55 2013 1 00012 021 0000123 21', { accept: ['birth'] isValidCertidao('104539 01 55 2013 1 00012 021 0000123 21', { accept: ['death'] }); // false ``` +## formatCertidao + +Format the matrícula of a certidão de registro civil into the printed mask of the Provimento, the 32 digits grouped as 6 2 2 4 1 5 3 7 2 and separated by spaces. `options.pad` (part of `FormatCertidaoOptions`) left pads the value with zeros up to 32 digits (default `false`). The mask is the one of [art. 473 of the Código Nacional de Normas da Corregedoria Nacional de Justiça](https://atos.cnj.jus.br/atos/detalhar/5243). A number is accepted and read as the string of its digits, like in `formatCpf`, but a full 32 digit matrícula has to be a string: that many digits are more than a JavaScript number can hold exactly. At runtime the value is read for its digits and masked as far as they go, like in every formatter of this package, so a partial matrícula still being typed is masked progressively. + +```javascript +import { formatCertidao } from '@brazilian-utils/brazilian-utils'; + +formatCertidao('10453901552013100012021000012321'); // 104539 01 55 2013 1 00012 021 0000123 21 +formatCertidao('104539.01.55.2013.1.00012.021.0000123-21'); // 104539 01 55 2013 1 00012 021 0000123 21 +formatCertidao('1552010100020112000012087', { pad: true }); // 000000 01 55 2010 1 00020 112 0000120 87 +formatCertidao(104539015520); // 104539 01 55 20 (a number is read as the string of its digits) +``` + ## parseCertidao -Parse the matrícula of a certidão de registro civil into its fields, returning `null` when the matrícula is not valid, which includes a book code that is not one of the nine books. [Art. 473, V of the Código Nacional de Normas da Corregedoria Nacional de Justiça](https://atos.cnj.jus.br/atos/detalhar/5243) lists the codes 1 to 7; the codes 8 (emancipação) and 9 (interdição) come from Anexo IV of the revoked Provimento CNJ nº 63/2017, as listed by [ghiorzi.org](http://ghiorzi.org/DVnew.htm), and are kept because matrículas issued under it are still in circulation. Only a string is accepted: the 32 digits of a matrícula are more than a JavaScript number can hold. +Remove the formatting of the matrícula of a certidão de registro civil, keep only digits, and cap the result to 32 digits. This only takes the mask off: use `isValidCertidao` to check the matrícula and `getCertidaoInfo` to read its fields. ```javascript import { parseCertidao } from '@brazilian-utils/brazilian-utils'; parseCertidao('104539 01 55 2013 1 00012 021 0000123 21'); +// '10453901552013100012021000012321' +``` + +## getCertidaoInfo + +Parse the matrícula of a certidão de registro civil into its fields, returning `null` when the matrícula is not valid, which includes a book code that is not one of the nine books. A serviço other than the `55` that art. 473, III fixes for the registro civil das pessoas naturais also gives `null`. [Art. 473, V of the Código Nacional de Normas da Corregedoria Nacional de Justiça](https://atos.cnj.jus.br/atos/detalhar/5243) lists the codes 1 to 7; no CNJ primary text reachable today publishes the other two, the Anexo IV of the revoked Provimento CNJ nº 63/2017 included, which lists the same seven. The codes 8 (emancipação) and 9 (interdição) come from the references the check digit rule rests on: [ghiorzi.org](http://ghiorzi.org/DVnew.htm) and [validation-br](https://github.com/klawdyo/validation-br/blob/feat-certidao/src/certidao.ts) both print the nine book list. They are kept because matrículas carrying them circulate. Only a string is accepted: the 32 digits of a matrícula are more than a JavaScript number can hold. + +```javascript +import { getCertidaoInfo } from '@brazilian-utils/brazilian-utils'; + +getCertidaoInfo('104539 01 55 2013 1 00012 021 0000123 21'); // { // registryCns: '104539', // acervo: '01', @@ -1576,15 +1792,15 @@ parseCertidao('104539 01 55 2013 1 00012 021 0000123 21'); // checkDigits: '21' // } -parseCertidao('invalid'); // null +getCertidaoInfo('invalid'); // null ``` -The `Certidao` result carries: +The `CertidaoInfo` result carries: | Key | Description | | --- | --- | | `registryCns` | The 6 digit CNS (Código Nacional de Serventia) of the serventia that issued the act. | -| `acervo` | Acervo the book belongs to: `"01"` the serventia's own, `"02"` a collection it absorbed. | +| `acervo` | Acervo the book belongs to: `"01"` the serventia's own, `"02"` and up one per acervo it absorbed. [Art. 473, §§ 3º to 5º](https://atos.cnj.jus.br/atos/detalhar/5243) splits the absorbed ones by the date the origin serventia was extinguished or deactivated: up to 31/12/2009 the matrícula carries the CNS of the incorporating unit and an acervo code from `"02"` up, one per incorporation; from 01/01/2010 on it carries the CNS of the incorporated unit itself and the code `"01"`, counted as that unit's own acervo; and an acervo split between two or more successor serventias gets each successor's own CNS with the code `"02"`. | | `service` | Service rendered by the serventia, always `"55"`, the registro civil das pessoas naturais. | | `year` | Four digit year the act was recorded. | | `type` | The book the act belongs to: `"birth"`, `"marriage"`, `"religious-marriage"`, `"death"`, `"stillbirth"`, `"banns"`, `"other"`, `"emancipation"` or `"interdiction"`. | @@ -1594,21 +1810,9 @@ The `Certidao` result carries: | `term` | The 7 digit term (termo) number, zero padded. | | `checkDigits` | The 2 modulus 11 check digits of the matrícula. | -## formatCertidao - -Format the matrícula of a certidão de registro civil into the printed mask of the Provimento, the 32 digits grouped as 6 2 2 4 1 5 3 7 2 and separated by spaces. `options.pad` (part of `FormatCertidaoOptions`) left pads the value with zeros up to 32 digits. The mask is the one of [art. 473 of the Código Nacional de Normas da Corregedoria Nacional de Justiça](https://atos.cnj.jus.br/atos/detalhar/5243). Only a string is accepted: the 32 digits of a matrícula are more than a JavaScript number can hold. - -```javascript -import { formatCertidao } from '@brazilian-utils/brazilian-utils'; - -formatCertidao('10453901552013100012021000012321'); // 104539 01 55 2013 1 00012 021 0000123 21 -formatCertidao('104539.01.55.2013.1.00012.021.0000123-21'); // 104539 01 55 2013 1 00012 021 0000123 21 -formatCertidao('1552010100020112000012087', { pad: true }); // 000000 01 55 2010 1 00020 112 0000120 87 -``` - ## isValidCei -Check if a CEI (Cadastro Específico do INSS) number is valid. The CEI identifies an employer with no CNPJ, such as a construction work or a rural producer: 12 digits printed as `00.000.00000/00`, the last one a check digit calculated over the 11 base digits with the weights 7, 4, 1, 8, 5, 2, 1, 6, 3, 7 and 4. Accepts the usual mask characters and whitespace between/around groups. The Receita Federal does not publish this check digit rule, so it follows the reference implementations of [yii2-br-validator](https://github.com/yiibr/yii2-br-validator/blob/master/src/CeiValidator.php) and [Bigai.Documentos.Brasil](https://github.com/marcos-cruz/Documento/blob/master/src/Bigai.Documentos.Brasil/Cei/Cei.cs), cross-checked against the [Cadastro Nacional de Obras (CNO) open dataset](https://dados.gov.br/dados/conjuntos-dados/cadastro-nacional-de-obras-cno) of the Receita Federal. +Check if a CEI (Cadastro Específico do INSS) number is valid. The CEI identifies an employer with no CNPJ, such as a construction work or a rural producer: 12 digits printed as `00.000.00000/00`, the last one a check digit calculated over the 11 base digits with the weights 7, 4, 1, 8, 5, 2, 1, 6, 3, 7 and 4. Accepts the usual mask characters and whitespace between/around groups, a run of them between two groups included. The Receita Federal does not publish this check digit rule, so it follows the reference implementations of [yii2-br-validator](https://github.com/yiibr/yii2-br-validator/blob/master/src/CeiValidator.php) and [Bigai.Documentos.Brasil](https://github.com/marcos-cruz/Documento/blob/master/src/Bigai.Documentos.Brasil/Cei/Cei.cs), cross-checked against the [Cadastro Nacional de Obras (CNO) open dataset](https://dados.gov.br/dados/conjuntos-dados/cadastro-nacional-de-obras-cno) of the Receita Federal. ```javascript import { isValidCei } from '@brazilian-utils/brazilian-utils'; @@ -1622,7 +1826,7 @@ isValidCei('000000000000'); // false (repeated digits) ## formatCei -Format a CEI (Cadastro Específico do INSS) number according to the usual `00.000.00000/00` mask, the one the reference implementations of the check digit agree on (the Receita Federal does not print it). Formats progressively, as far as the digits given go, so it can also be used as an input mask. `options.pad` (part of `FormatCeiOptions`) left pads the value with zeros up to 12 digits. +Format a CEI (Cadastro Específico do INSS) number according to the usual `00.000.00000/00` mask, the one the reference implementations of the check digit agree on (the Receita Federal does not print it). Formats progressively, as far as the digits given go, so it can also be used as an input mask. `options.pad` (part of `FormatCeiOptions`) left pads the value with zeros up to 12 digits (default `false`). ```javascript import { formatCei } from '@brazilian-utils/brazilian-utils'; @@ -1632,9 +1836,19 @@ formatCei(249859674386); // 24.985.96743/86 formatCei('249', { pad: true }); // 00.000.00002/49 ``` +## parseCei + +Remove CEI (Cadastro Específico do INSS) formatting, keep only digits, and cap the result to 12 digits. A partial value passes through as far as it goes; use `isValidCei` to check the number itself. + +```javascript +import { parseCei } from '@brazilian-utils/brazilian-utils'; + +parseCei('27.729.71181/87'); // '277297118187' +``` + ## isValidCno -Check if a CNO (Cadastro Nacional de Obras) number is valid. The CNO replaced the CEI for construction works and kept its numbering, so a work registered under a legacy CEI keeps the same number and both registries validate identically: 12 digits printed as `00.000.00000/00` with a check digit calculated over the 11 base digits. The Receita Federal does not publish the check digit rule; it was confirmed against the [Cadastro Nacional de Obras (CNO) open dataset](https://dados.gov.br/dados/conjuntos-dados/cadastro-nacional-de-obras-cno) of the Receita Federal: every one of the 38432 works registered in Minas Gerais passes this check. +Check if a CNO (Cadastro Nacional de Obras) number is valid. The CNO replaced the CEI for construction works and kept its numbering, so a work registered under a legacy CEI keeps the same number and both registries validate identically: 12 digits printed as `00.000.00000/00` with a check digit calculated over the 11 base digits. The Receita Federal does not publish the check digit rule; it was confirmed against the [Cadastro Nacional de Obras (CNO) open dataset](https://dados.gov.br/dados/conjuntos-dados/cadastro-nacional-de-obras-cno) of the Receita Federal: every work in the Minas Gerais extract of that dataset passes this check. The catalogue page itself publishes only the dataset's description and download links, not that result. ```javascript import { isValidCno } from '@brazilian-utils/brazilian-utils'; @@ -1648,7 +1862,7 @@ isValidCno('000000000000'); // false (repeated digits) ## formatCno -Format a CNO (Cadastro Nacional de Obras) number. The CNO kept the CEI's numbering, so both share the same 12 digit, `00.000.00000/00` mask, the one the reference implementations of the check digit agree on (the Receita Federal does not print it). Formats progressively, as far as the digits given go, so it can also be used as an input mask. `options.pad` (part of `FormatCnoOptions`) left pads the value with zeros up to 12 digits. +Format a CNO (Cadastro Nacional de Obras) number. The CNO kept the CEI's numbering, so both share the same 12 digit, `00.000.00000/00` mask, the one the reference implementations of the check digit agree on (the Receita Federal does not print it). Formats progressively, as far as the digits given go, so it can also be used as an input mask. `options.pad` (part of `FormatCnoOptions`) left pads the value with zeros up to 12 digits (default `false`). ```javascript import { formatCno } from '@brazilian-utils/brazilian-utils'; @@ -1658,9 +1872,19 @@ formatCno(401800097960); // 40.180.00979/60 formatCno('979', { pad: true }); // 00.000.00009/79 ``` +## parseCno + +Remove CNO (Cadastro Nacional de Obras) formatting, keep only digits, and cap the result to 12 digits, the numbering the CNO kept from the CEI. A shorter value passes through as far as it goes; use `isValidCno` to check the number itself. + +```javascript +import { parseCno } from '@brazilian-utils/brazilian-utils'; + +parseCno('11.113.01373/68'); // '111130137368' +``` + ## isValidCaepf -Check if a CAEPF (Cadastro de Atividade Econômica da Pessoa Física) number is valid. The CAEPF replaced the CEI for individuals who hire employees: 14 digits printed as `000.000.000/000-00`, formed by the 9 digit CPF base of the holder, a 3 digit sequence for the holder's several registrations and 2 check digits. Both check digits use the modulus 11 of the CNPJ, and the resulting pair is then shifted by 12, wrapping around 100. The Receita Federal does not publish the layout or the check digit rule: both are described by [ghiorzi.org](http://ghiorzi.org/DVnew.htm) and implemented the same way by [brazilian-values](https://github.com/VitorLuizC/brazilian-values/blob/master/src/validators/isCAEPF.ts). +Check if a CAEPF (Cadastro de Atividade Econômica da Pessoa Física) number is valid. The CAEPF replaced the CEI for individuals who hire employees: 14 digits printed as `000.000.000/000-00`, formed by the 9 digit CPF base of the holder, a 3 digit sequence for the holder's several registrations and 2 check digits. Both check digits are the CNPJ's modulus 11 in the formulation of the cited reference: the weights cycle from 9 down to 2 from the right and the check digit is the remainder itself, with a remainder of 10 read as 0 — the same digit the CNPJ's 2-to-9 weights with `11 - remainder` produce. The resulting pair is then shifted by 12, wrapping around 100. A base whose 12 digits are all the same is rejected before the check digits are computed, the way `isValidCei` and `isValidCno` reject a repeated CEI/CNO number, so the otherwise well-formed `00000000000012` is invalid. The Receita Federal does not publish the layout or the check digit rule: both are described by [ghiorzi.org](http://ghiorzi.org/DVnew.htm) and implemented the same way by [brazilian-values](https://github.com/VitorLuizC/brazilian-values/blob/master/src/validators/isCAEPF.ts). ```javascript import { isValidCaepf } from '@brazilian-utils/brazilian-utils'; @@ -1669,12 +1893,13 @@ isValidCaepf('293.118.610/001-84'); // true isValidCaepf('41142260000101'); // true isValidCaepf(29311861000184); // true isValidCaepf('29311861000185'); // false (invalid check digits) -isValidCaepf('00000000000000'); // false (repeated digits) +isValidCaepf('00000000000000'); // false (repeated base digits) +isValidCaepf('00000000000012'); // false (repeated base digits) ``` ## formatCaepf -Format a CAEPF (Cadastro de Atividade Econômica da Pessoa Física) number according to the usual `000.000.000/000-00` mask, the one the sources of the check digit rule agree on (the Receita Federal does not print it). Formats progressively, as far as the digits given go, so it can also be used as an input mask. `options.pad` (part of `FormatCaepfOptions`) left pads the value with zeros up to 14 digits. +Format a CAEPF (Cadastro de Atividade Econômica da Pessoa Física) number according to the usual `000.000.000/000-00` mask, the one the sources of the check digit rule agree on (the Receita Federal does not print it). Formats progressively, as far as the digits given go, so it can also be used as an input mask. `options.pad` (part of `FormatCaepfOptions`) left pads the value with zeros up to 14 digits (default `false`). ```javascript import { formatCaepf } from '@brazilian-utils/brazilian-utils'; @@ -1684,22 +1909,34 @@ formatCaepf(41142260000101); // 411.422.600/001-01 formatCaepf('184', { pad: true }); // 000.000.000/001-84 ``` +## parseCaepf + +Remove CAEPF (Cadastro de Atividade Econômica da Pessoa Física) formatting, keep only digits, and cap the result to 14 digits. A shorter value passes through as far as it goes; use `isValidCaepf` to check the number itself. + +```javascript +import { parseCaepf } from '@brazilian-utils/brazilian-utils'; + +parseCaepf('293.118.610/001-84'); // '29311861000184' +``` + ## isValidRegistroProfissional -Check the structure of a professional council registration number (registro/inscrição profissional). Options are typed as `IsValidRegistroProfissionalOptions`: `options.council` picks the issuing council (`"OAB"`, `"CRM"`, `"CRO"`, `"CRP"` or `"CRC"`) and the optional `options.stateCode` checks the embedded UF (ignored for `"CRP"`, whose 2 digit prefix is a regional code, not a literal UF). This is a structural check only: digit counts and the UF are validated, but no check digit is computed, even for CRC, whose format includes one. A CRC registration is the UF, 6 digits and the tipo de registro (`"O"` Originário, `"P"` Provisório or `"T"` Transferido, which says nothing about the professional category), as published in the [Manual de Registro do Sistema CFC/CRCs](https://cfc.org.br/wp-content/uploads/2018/04/1_manual_registro.pdf) (item 1.1) and in the Resolução CFC nº 1.707/2023. A CRP regional code has to be one of the [24 Conselhos Regionais](https://site.cfp.org.br/cfp/sistema-conselhos/conselhos-pelo-brasil/) of the CFP system, CRP-01 to CRP-24. The OAB, the CFM and the CFO publish no format for the numbers they issue, so the digit ranges accepted for `"OAB"`, `"CRM"` and `"CRO"` are conventional rather than normative. CREA is not supported: its registration format could not be confirmed from an official, publicly documented source after the 2016 national unification (RNP). +Check the structure of a professional council registration number (registro/inscrição profissional). It takes a single object, typed as `IsValidRegistroProfissionalOptions`, the shape `isValidBankAccount` takes: `value` is the registration number, `council` picks the issuing council (`"OAB"`, `"CRM"`, `"CRO"`, `"CRP"` or `"CRC"`) and the optional `stateCode` checks the embedded UF (ignored for `"CRP"`, whose 2 digit prefix is a regional code, not a literal UF). Anything that is not an object, and an object missing `value` or `council`, is `false`. The accepted shapes are 4 to 6 digits plus the UF for `"OAB"` and `"CRM"`, 3 to 6 digits plus the UF for `"CRO"`, a 2 digit regional code plus 4 to 6 digits for `"CRP"`, and the UF plus 6 digits, the tipo de registro and one check digit for `"CRC"`. This is a structural check only: digit counts and the UF are validated, but no check digit is computed, even for CRC, whose format includes one. A CRC registration is the UF, 6 digits, the tipo de registro (`"O"` Originário or `"P"` Provisório, which says nothing about the professional category) and the check digit, as published in the [Manual de Registro do Sistema CFC/CRCs](https://cfc.org.br/wp-content/uploads/2018/04/1_manual_registro.pdf) (item 1.1). A Registro Transferido or Secundário appends `"T"` or `"S"` and the UF of the destination CRC **after** the check digit, per that same item and [Resolução CFC nº 1.707/2023](https://www1.cfc.org.br/sisweb/SRE/docs/Res_1707.pdf), art. 5º parágrafo único: the Manual's own examples are `SP-123456/O-3 T-MG`, `TO-654321/P-8 T-SC` and `PI-111222/O-5 S-AC`. Both UFs must be real state codes, and `stateCode` is compared against the originating one. A CRP regional code has to be one of the [24 Conselhos Regionais](https://site.cfp.org.br/cfp/sistema-conselhos/conselhos-pelo-brasil/) of the CFP system, CRP-01 to CRP-24. Only the CRC shape and those CRP regional codes rest on a published source: the CFP page publishes no length for the inscription number itself, and the OAB, the CFM and the CFO publish no format at all, so the digit ranges accepted for `"CRP"`, `"OAB"`, `"CRM"` and `"CRO"` are conventional rather than normative (the OAB/SP public search field is `maxlength="7"`, and the CFM documents `300`-prefixed and `P`-suffixed CRMs, none of which these shapes express). CREA is not supported: its registration format could not be confirmed from an official, publicly documented source after the 2016 national unification (RNP). ```javascript import { isValidRegistroProfissional } from '@brazilian-utils/brazilian-utils'; -isValidRegistroProfissional('123456/SP', { council: 'OAB' }); // true -isValidRegistroProfissional('123456-RJ', { council: 'OAB', stateCode: 'SP' }); // false (UF mismatch) -isValidRegistroProfissional('06/12345', { council: 'CRP' }); // true -isValidRegistroProfissional('SP-123456/O-3', { council: 'CRC' }); // true +isValidRegistroProfissional({ value: '123456/SP', council: 'OAB' }); // true +isValidRegistroProfissional({ value: '123456-RJ', council: 'OAB', stateCode: 'SP' }); // false (UF mismatch) +isValidRegistroProfissional({ value: '06/12345', council: 'CRP' }); // true +isValidRegistroProfissional({ value: 'SP-123456/O-3', council: 'CRC' }); // true +isValidRegistroProfissional({ value: 'SP-123456/O-3 T-MG', council: 'CRC' }); // true (registro transferido) +isValidRegistroProfissional({ value: 'SP-123456/T-3', council: 'CRC' }); // false ("T" is not a tipo de registro) ``` ## isValidVin -Check if a VIN (Vehicle Identification Number / chassi) is valid. Checks the length (17 characters), the excluded letters (`I`, `O`, `Q` are never valid; [ISO 3779:2009](https://www.iso.org/standard/52200.html) structure) and the check digit at the 9th position, with the check digit and transliteration computed per [49 CFR 565.15](https://www.ecfr.gov/current/title-49/section-565.15). That check digit is a North-American requirement (49 CFR 565.15 / SAE J853): Resolução CONTRAN nº 24/1998 and ABNT NBR 6066 define the Brazilian VIN structure but do not mandate it, so many Brazilian-built VINs do not carry a matching check digit. This function is therefore a North-American-style structural check, not a universal validator of Brazilian VINs. Case-insensitive and trims surrounding whitespace. +Check if a VIN (Vehicle Identification Number / chassi) is valid. Checks the length (17 characters), the excluded letters (`I`, `O`, `Q` are never valid; [ISO 3779:2009](https://www.iso.org/standard/52200.html) structure) and the check digit at the 9th position, with the check digit and transliteration computed per [49 CFR 565.15](https://www.ecfr.gov/current/title-49/section-565.15). That check digit is a North-American requirement (49 CFR 565.15 / SAE J853): [Resolução CONTRAN nº 968/2022](https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes/resolucao9682022.pdf) (which revoked Resolução CONTRAN nº 24/1998 from 1 January 2025) and ABNT NBR 6066 define the Brazilian VIN structure but do not mandate it, so many Brazilian-built VINs do not carry a matching check digit. This function is therefore a North-American-style structural check, not a universal validator of Brazilian VINs. Case-insensitive and trims surrounding whitespace. A VIN is printed as one unbroken run of 17 characters, so, unlike the documents this package masks (`isValidCpf`, `isValidCnpj`, `isValidNfeKey`), it has no group boundary to write a separator at and none is accepted: a space, `.`, `-` or `/` among the characters is rejected instead of being stripped. A value whose 17 characters are all the same (`'00000000000000000'`) is rejected even when it carries a matching check digit, the way every other validator of this package rejects a repeated-digit document. ```javascript import { isValidVin } from '@brazilian-utils/brazilian-utils'; @@ -1707,12 +1944,13 @@ import { isValidVin } from '@brazilian-utils/brazilian-utils'; isValidVin('1HGCM82633A004352'); // true isValidVin('1m8gdm9axkp042788'); // true (check digit X, lowercase) isValidVin('1HGCM82633A004353'); // false (bad check digit) +isValidVin('00000000000000000'); // false (every character the same, though the check digit matches) isValidVin('1HGCM8263IA004352'); // false (contains the excluded letter I) ``` ## isValidCbo -Check if a CBO (Classificação Brasileira de Ocupações) code exists in the MTE occupation table. Accepts the code with or without the hyphen mask, or as a number. A string is only read as a code when it is written in one of those forms (the 6 digits, or the `NNNN-NN` mask, with the usual separators between the groups and optional surrounding whitespace), and a number only when it is a non-negative safe integer. +Check if a CBO (Classificação Brasileira de Ocupações) code exists in the MTE occupation table. Accepts the code with or without the hyphen mask, or as a number. A string is only read as a code when it is written in one of those forms (the 6 digits, or the `NNNN-NN` mask, with a single separator between the groups and optional surrounding whitespace), and a number only when it is a non-negative safe integer. A CBO code is always 6 digits and its leading zeros are part of it, so a value written as bare digits is left padded with zeros to 6 whether it comes as a string or as a number, exactly like `getBankByCode` pads a bank code: `10205`, `'10205'` and `'010205'` are the same code. A masked value already carries its separators and is read as written. ```javascript import { isValidCbo } from '@brazilian-utils/brazilian-utils'; @@ -1720,36 +1958,52 @@ import { isValidCbo } from '@brazilian-utils/brazilian-utils'; isValidCbo('2124-05'); // true isValidCbo('212405'); // true isValidCbo(212405); // true +isValidCbo(10205); // true (padded to 6 digits, so this is '010205') +isValidCbo('10205'); // true (padded the same way a number is) isValidCbo('000000'); // false isValidCbo('2124abc05'); // false (not a documented form) isValidCbo(-212405); // false (not a non-negative safe integer) ``` -The occupation titles come from the [official CBO 2002 tables published by the MTE](http://www.mtecbo.gov.br/cbosite/pages/downloads.jsf). +The occupation titles come from the [official CBO 2002 occupation table published by the MTE](https://www.gov.br/trabalho-e-emprego/pt-br/assuntos/cbo/servicos/downloads/cbo2002-ocupacao.csv). + +## parseCbo + +Remove CBO (Classificação Brasileira de Ocupações) formatting, keep only digits, and cap the result to 6 digits. A shorter value passes through as far as it goes and nothing is left padded here, so the leading zero of a code such as `010205` has to be written out; use `getCbo` or `isValidCbo`, which do pad a bare numeric code, to look an occupation up. + +```javascript +import { parseCbo } from '@brazilian-utils/brazilian-utils'; + +parseCbo('2124-05'); // '212405' +``` ## getCbo -Look a CBO (Classificação Brasileira de Ocupações) code up and get its official occupation title. A `number` keeps its implied leading zeros: `getCbo(10205)` is read as `010205`. Same input rules as `isValidCbo`: a string has to be written as the 6 digits or with the `NNNN-NN` mask, and a number has to be a non-negative safe integer. +Look a CBO (Classificação Brasileira de Ocupações) code up and get its official occupation title, in the `{ code, description }` record every lookup of this library returns. A value written as bare digits keeps its implied leading zeros, as a string as much as a number: `getCbo(10205)` and `getCbo('10205')` are both read as `010205`. Same input rules as `isValidCbo`: a string has to be written as the 6 digits or with the `NNNN-NN` mask, and a number has to be a non-negative safe integer. ```javascript import { getCbo } from '@brazilian-utils/brazilian-utils'; -getCbo('2124-05'); // { code: '212405', title: 'Analista de desenvolvimento de sistemas' } +getCbo('2124-05'); // { code: '212405', description: 'Analista de desenvolvimento de sistemas' } +getCbo(10205); // { code: '010205', description: 'Oficial da aeronáutica' } (padded to 6 digits) +getCbo('10205'); // { code: '010205', description: 'Oficial da aeronáutica' } (padded the same way) getCbo('000000'); // null getCbo('2124abc05'); // null (not a documented form) ``` -The occupation titles come from the [official CBO 2002 tables published by the MTE](http://www.mtecbo.gov.br/cbosite/pages/downloads.jsf). +The occupation titles come from the [official CBO 2002 occupation table published by the MTE](https://www.gov.br/trabalho-e-emprego/pt-br/assuntos/cbo/servicos/downloads/cbo2002-ocupacao.csv). ## isValidCnae -Check if a CNAE (Classificação Nacional de Atividades Econômicas) subclass code exists in the CNAE 2.3 table published by IBGE. Accepts the code with or without the `NNNN-N/NN` mask, or as a number. A string is only read as a code when it is written in one of those forms (the 7 digits, or the mask, with the usual separators between the groups and optional surrounding whitespace), and a number only when it is a non-negative safe integer. +Check if a CNAE (Classificação Nacional de Atividades Econômicas) subclass code exists in the [CNAE-Subclasses 2.3 table published by IBGE](https://concla.ibge.gov.br/busca-online-cnae.html), the current subclass revision of CNAE 2.0. Accepts the code with or without the `NNNN-N/NN` mask, or as a number. A string is only read as a code when it is written in one of those forms (the 7 digits, or the mask, with a single separator between the groups and optional surrounding whitespace), and a number only when it is a non-negative safe integer. A CNAE subclass code is always 7 digits and its leading zeros are part of it, so a value written as bare digits is left padded with zeros to 7 whether it comes as a string or as a number: `111301`, `'111301'` and `'0111301'` are the same code. A masked value already carries its separators and is read as written. ```javascript import { isValidCnae } from '@brazilian-utils/brazilian-utils'; isValidCnae('6201-5/01'); // true isValidCnae('6201501'); // true +isValidCnae(111301); // true (padded to 7 digits, so this is '0111301') +isValidCnae('111301'); // true (padded the same way a number is) isValidCnae('0000000'); // false isValidCnae('0111abc301'); // false (not a documented form) isValidCnae(-111301); // false (not a non-negative safe integer) @@ -1757,70 +2011,128 @@ isValidCnae(-111301); // false (not a non-negative safe integer) ## formatCnae -Format a CNAE (Classificação Nacional de Atividades Econômicas) subclass code. +Format a CNAE (Classificação Nacional de Atividades Econômicas) subclass code. `options.pad` (part of `FormatCnaeOptions`) works exactly like it does in `formatCpf`/`formatCep`: with the default `false` the mask is applied progressively, as far as the value goes, which is what an input being typed into needs; with `true` the value is first left padded with zeros to the 7 digits of a complete subclass code, so it always comes back fully masked. A number is treated exactly like the string of its digits, so it is only padded under `pad: true`. Like every formatter of this package, the value is read for its digits and masked as far as they go: characters outside the mask are dropped and a number is read as the string of its digits, sign and decimal point included. Use `isValidCnae` to check a code. ```javascript import { formatCnae } from '@brazilian-utils/brazilian-utils'; formatCnae('6201501'); // 6201-5/01 +formatCnae('62'); // 62 (masked as far as it goes) +formatCnae('62015'); // 6201-5 +formatCnae('62', { pad: true }); // 0000-0/62 (padded to 7 digits first) +formatCnae(111301, { pad: true }); // 0111-3/01 +formatCnae('abc6201501'); // 6201-5/01 (only the digits are read) +formatCnae(-6201501); // 6201-5/01 +``` + +## parseCnae + +Remove CNAE (Classificação Nacional de Atividades Econômicas) formatting, keep only digits, and cap the result to the 7 digits of a complete subclass code. Nothing is left padded here; use `getCnae` or `isValidCnae`, which do pad a bare numeric code, to look a subclass up. + +```javascript +import { parseCnae } from '@brazilian-utils/brazilian-utils'; + +parseCnae('6201-5/01'); // '6201501' +parseCnae('62'); // '62' (a partial code is kept as written) ``` ## getCnae -Look a CNAE (Classificação Nacional de Atividades Econômicas) subclass code up and get its formatted code and official description. A `number` keeps its implied leading zeros: `getCnae(111301)` is read as `0111301`. Same input rules as `isValidCnae`: a string has to be written as the 7 digits or with the `NNNN-N/NN` mask, and a number has to be a non-negative safe integer. +Look a CNAE (Classificação Nacional de Atividades Econômicas) subclass code up and get its code and official description. `code` comes back as the 7 bare digits, like every other lookup of this library; pass it to `formatCnae` for the `NNNN-N/NN` form. A value written as bare digits keeps its implied leading zeros, as a string as much as a number: `getCnae(111301)` and `getCnae('111301')` are both read as `0111301`. Same input rules as `isValidCnae`: a string has to be written as the 7 digits or with the `NNNN-N/NN` mask, and a number has to be a non-negative safe integer. ```javascript -import { getCnae } from '@brazilian-utils/brazilian-utils'; +import { formatCnae, getCnae } from '@brazilian-utils/brazilian-utils'; -getCnae('6201501'); // { code: '6201-5/01', description: 'DESENVOLVIMENTO DE PROGRAMAS DE COMPUTADOR SOB ENCOMENDA' } +getCnae('6201-5/01'); // { code: '6201501', description: 'DESENVOLVIMENTO DE PROGRAMAS DE COMPUTADOR SOB ENCOMENDA' } +getCnae(111301); // { code: '0111301', description: 'CULTIVO DE ARROZ' } (padded to 7 digits) +getCnae('111301'); // { code: '0111301', description: 'CULTIVO DE ARROZ' } (padded the same way) getCnae('0000000'); // null getCnae('0111abc301'); // null (not a documented form) +formatCnae(getCnae('6201501')?.code); // 6201-5/01 (the mask is the formatter's job) ``` ## isValidNcm -Check if an NCM (Nomenclatura Comum do Mercosul) code exists in the current table published by Siscomex/MDIC. Accepts the code with or without the dotted mask, or as a number. +Check if an NCM (Nomenclatura Comum do Mercosul) code exists in the current table published by Siscomex/MDIC. Accepts the code with or without the dotted mask, or as a number. A string is only read as a code when it is written in one of those forms (the 8 digits, or the `NNNN.NN.NN` mask, with a single separator between the groups and optional surrounding whitespace), and a number only when it is a non-negative safe integer. An NCM code is always 8 digits and its leading zeros are part of it, so a value written as bare digits is left padded with zeros to 8 whether it comes as a string or as a number: `1012100`, `'1012100'` and `'01012100'` are the same code. A masked value already carries its separators and is read as written. ```javascript import { isValidNcm } from '@brazilian-utils/brazilian-utils'; isValidNcm('8471.30.12'); // true isValidNcm('84713012'); // true +isValidNcm(1012100); // true (padded to 8 digits, so this is '01012100') +isValidNcm('1012100'); // true (padded the same way a number is) isValidNcm('00000000'); // false +isValidNcm('abc01012100'); // false (not a documented form) +isValidNcm(-84713012); // false (not a non-negative safe integer) ``` ## formatNcm -Format an NCM (Nomenclatura Comum do Mercosul) code. +Format an NCM (Nomenclatura Comum do Mercosul) code. `options.pad` (part of `FormatNcmOptions`) works exactly like it does in `formatCpf`/`formatCep`: with the default `false` the mask is applied progressively, as far as the value goes, which is what an input being typed into needs; with `true` the value is first left padded with zeros to the 8 digits of a complete code, so it always comes back fully masked. A number is treated exactly like the string of its digits, so it is only padded under `pad: true`. Like every formatter of this package, the value is read for its digits and masked as far as they go: characters outside the mask are dropped and a number is read as the string of its digits, sign and decimal point included. Use `isValidNcm` to check a code. ```javascript import { formatNcm } from '@brazilian-utils/brazilian-utils'; formatNcm('84713012'); // 8471.30.12 +formatNcm('8471'); // 8471 (masked as far as it goes) +formatNcm('847130'); // 8471.30 +formatNcm('8471', { pad: true }); // 0000.84.71 (padded to 8 digits first) +formatNcm('abc8471'); // 8471 (only the digits are read) +formatNcm(-84713012); // 8471.30.12 +``` + +## parseNcm + +Remove NCM (Nomenclatura Comum do Mercosul) formatting, keep only digits, and cap the result to the 8 digits of a complete code. Nothing is left padded here; use `isValidNcm`, which does pad a bare numeric code, to check a code against the official table. + +```javascript +import { parseNcm } from '@brazilian-utils/brazilian-utils'; + +parseNcm('8471.30.12'); // '84713012' +parseNcm('8471'); // '8471' (a partial code is kept as written) ``` ## isValidCfop -Check if a CFOP (Código Fiscal de Operações e Prestações) code exists in the official table (Ajuste SINIEF 07/2001 and updates). Only operable codes count: the group and subgroup headings of the official nomenclature, the codes ending in `00` and `50` (1000, 1100, 1150, 5350, ...), are section titles rather than codes a document can carry, so they are rejected. +Check if a CFOP (Código Fiscal de Operações e Prestações) code exists in the official table. The table is the [consolidated Anexo II of Convênio SINIEF s/nº 1970](https://www.confaz.fazenda.gov.br/legislacao/ajustes/sinief/cfop_cvsn_1-6.24), the text in force (current wording given by Ajuste SINIEF 03/24, last amended by [Ajuste SINIEF 39/25](https://www.confaz.fazenda.gov.br/legislacao/ajustes/2025/AJ039_25)), not the frozen 2001 text of Ajuste SINIEF 07/01. Only operable codes count: the group and subgroup headings of the official nomenclature, the codes ending in `00` and `50` (1000, 1100, 1150, 5350, ...), are section titles rather than codes a document can carry, so they are rejected. + +A string is only read as a code when it is written in one of the documented forms (the 4 digits, or the `N.NNN` form the annex prints, with a single separator between the groups and optional surrounding whitespace), and a number only when it is a non-negative safe integer. No CFOP code starts with a zero, its first digit is the operation group (1 to 7), so nothing is ever padded here: a number and the string of the same digits are read identically. ```javascript import { isValidCfop } from '@brazilian-utils/brazilian-utils'; isValidCfop('5102'); // true +isValidCfop('1.101'); // true +isValidCfop('7504'); // true (added by the 2022 rewrite of the annex) isValidCfop('0000'); // false isValidCfop('1150'); // false (a subgroup heading, not an operable code) +isValidCfop('abc5102'); // false (not a documented form) +isValidCfop(-5102); // false (not a non-negative safe integer) +``` + +## parseCfop + +Remove CFOP (Código Fiscal de Operações e Prestações) formatting, keep only digits, and cap the result to 4 digits. A shorter value passes through as far as it goes. No CFOP code starts with a zero, its first digit is the operation group from 1 to 7, so nothing is ever padded here. + +```javascript +import { parseCfop } from '@brazilian-utils/brazilian-utils'; + +parseCfop('5.102'); // '5102' ``` ## getCfop -Look a CFOP (Código Fiscal de Operações e Prestações) code up and get its code and official description. The group and subgroup headings of the official nomenclature, the codes ending in `00` and `50`, are not in the table and give `null`. +Look a CFOP (Código Fiscal de Operações e Prestações) code up and get its code and official description, as the [consolidated Anexo II of Convênio SINIEF s/nº 1970](https://www.confaz.fazenda.gov.br/legislacao/ajustes/sinief/cfop_cvsn_1-6.24) words it, in the text in force, last amended by [Ajuste SINIEF 39/25](https://www.confaz.fazenda.gov.br/legislacao/ajustes/2025/AJ039_25). The group and subgroup headings of the official nomenclature, the codes ending in `00` and `50`, are not in the table and give `null`. Same input rules as `isValidCfop`. ```javascript import { getCfop } from '@brazilian-utils/brazilian-utils'; -getCfop('5102'); // { code: '5102', description: 'Venda de mercadoria adquirida ou recebida de terceiros' } +getCfop('1101'); // { code: '1101', description: 'Compra para industrialização ou produção rural' } +getCfop('7504'); // { code: '7504', description: 'Exportação de mercadoria que foi objeto de formação de lote de exportação' } getCfop('0000'); // null getCfop('5350'); // null (a subgroup heading, not an operable code) +getCfop('abc5102'); // null (not a documented form) ``` ## isValidCst @@ -1829,33 +2141,49 @@ Check if a CST (Código de Situação Tributária) code is valid for a given tax | Tax | Format | Accepted codes | | --- | --- | --- | -| `icms` | 3 digits (origem + CST) | origem `0`-`8` + one of `00`, `10`, `20`, `30`, `40`, `41`, `50`, `51`, `60`, `70`, `90` | +| `icms` | 3 digits (origem + CST) | origem `0`-`8` + one of `00`, `02`, `10`, `15`, `20`, `30`, `40`, `41`, `50`, `51`, `53`, `60`, `61`, `70`, `90` | | `ipi` | 2 digits | `00`, `01`, `02`, `03`, `04`, `05`, `49`, `50`, `51`, `52`, `53`, `54`, `55`, `99` | | `pis` | 2 digits | `01`-`09`, `49`, `50`-`56`, `60`-`67`, `70`-`75`, `98`, `99` | | `cofins` | 2 digits | same table as `pis` | -`options.tax` (part of `IsValidCstOptions`) is optional: omit it to accept a code that exists in any one of the four tables above. +`options.tax` (part of `IsValidCstOptions`) is optional: omit it to accept a code that exists in any one of the four tables above. A `tax` outside those four values falls back to that same default at runtime, the way every other scalar option of this library treats a value it does not know. + +The ICMS Tabela B is the one in force: the [consolidated Anexo I of Convênio SINIEF s/nº 1970](https://www.confaz.fazenda.gov.br/legislacao/ajustes/sinief/cvsn_70), whose current wording came from [Ajuste SINIEF 39/23](https://www.confaz.fazenda.gov.br/legislacao/ajustes/2023/ajuste-sinief-39-23) (effective 01.12.23) and which [Ajuste SINIEF 20/24](https://www.confaz.fazenda.gov.br/legislacao/ajustes/2024/AJ020_24) amended by striking items 12, 13, 52, 72 and 74 (effects from 09.07.24) before they ever took effect: 39/23 had deferred their effect to 1º de outubro de 2024, so the revocation reached them first and those codes were never in force. `02`, `15`, `53` and `61` are its monofasia de combustíveis codes. + +A string is only read as a code when it is written in one of the documented forms (the 2 digits of a Tabela B code, or the 3 digits of the ICMS form with an optional single separator after the origin digit, plus optional surrounding whitespace), and a number only when it is a non-negative safe integer. The origin digit is the only boundary a printed CST has, so `'0 10'` and `'1-10'` are read while `'0-0'`, `'11-0'` and `'00-'` are not. + +A single digit is narrower than either documented form, so it is left padded with zeros to the 3 digits of the ICMS form, whether it comes as a string or as a number: `0`, `'0'` and `'000'` are all the ICMS code `000`. A 2 digit value is already a documented form, a Tabela B code, and is read as written, so a Tabela B code keeps its own two digits: `'07'`, not `7`, which is the ICMS code `007`. ```javascript import { isValidCst } from '@brazilian-utils/brazilian-utils'; isValidCst('000', { tax: 'icms' }); // true +isValidCst(0, { tax: 'icms' }); // true (a single digit is padded to the 3 digit form, '000') +isValidCst('0', { tax: 'icms' }); // true (padded the same way a number is) isValidCst('110', { tax: 'icms' }); // true +isValidCst('002', { tax: 'icms' }); // true (monofasia de combustíveis) isValidCst('06', { tax: 'pis' }); // true isValidCst('99', { tax: 'ipi' }); // true isValidCst('110'); // true (found in the icms table, tax omitted) +isValidCst('000', { tax: 'nope' }); // true (an unknown tax falls back to every table) isValidCst('999'); // false (not in any table) +isValidCst('abc110'); // false (not a documented form) +isValidCst(-110); // false (not a non-negative safe integer) ``` ## isValidCsosn -Check if a CSOSN (Código de Situação da Operação no Simples Nacional) code is one of the 10 codes defined by Ajuste SINIEF 03/2010: `101`, `102`, `103`, `201`, `202`, `203`, `300`, `400`, `500` or `900`. +Check if a CSOSN (Código de Situação da Operação no Simples Nacional) code is one of the 10 codes of the [consolidated Anexo III-A of Convênio SINIEF s/nº 1970](https://www.confaz.fazenda.gov.br/legislacao/ajustes/sinief/cvsn_70), the table Ajuste SINIEF 03/2010 instituted: `101`, `102`, `103`, `201`, `202`, `203`, `300`, `400`, `500` or `900`. + +A string is only read as a code when it is written as the bare 3 digits with optional surrounding whitespace: a CSOSN has no printed grouping (the NF-e carries the origin digit in its own `orig` field), so `'1-01'` is rejected; a number is read only when it is a non-negative safe integer. No CSOSN code starts with a zero, the table runs from `101` to `900`, so nothing is ever padded here: a number and the string of the same digits are read identically. ```javascript import { isValidCsosn } from '@brazilian-utils/brazilian-utils'; isValidCsosn('101'); // true isValidCsosn('999'); // false +isValidCsosn('abc101'); // false (not a documented form) +isValidCsosn(-101); // false (not a non-negative safe integer) ``` ## removeAccents diff --git a/package.json b/package.json index d0d32757..476f5bcd 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "url": "git+https://github.com/brazilian-utils/javascript.git" }, "files": [ + "./CHANGELOG.md", "./dist" ], "type": "module", @@ -45,6 +46,19 @@ "main": "./dist/brazilian-utils.umd.cjs", "module": "./dist/brazilian-utils.js", "types": "./dist/brazilian-utils.d.ts", + "typesVersions": { + "*": { + "dist/*": [ + "dist/*" + ], + "package.json": [ + "package.json" + ], + "*": [ + "dist/*.d.ts" + ] + } + }, "exports": { ".": { "import": { @@ -93,7 +107,8 @@ "check:duplication": "jscpd", "check:unused": "knip", "test:mutation": "stryker run", - "check:api": "npm run build && api-extractor run --local --verbose", + "check:api": "npm run build && api-extractor run --verbose", + "check:api:update": "npm run build && api-extractor run --local --verbose", "check:commits": "commitlint --from origin/main --to HEAD --verbose", "check:lockfile": "lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-https --validate-integrity", "build:data": "node ./scripts/data.ts", diff --git a/release-please-config.json b/release-please-config.json index fc2cdc16..723971a0 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -11,8 +11,8 @@ { "type": "perf", "section": "Performance" }, { "type": "revert", "section": "Reverts" }, { "type": "docs", "section": "Documentation" }, - { "type": "build", "section": "Build System" }, - { "type": "ci", "section": "CI" }, + { "type": "build", "section": "Build System", "hidden": true }, + { "type": "ci", "section": "CI", "hidden": true }, { "type": "deps", "section": "Dependencies" }, { "type": "chore", "scope": "deps", "section": "Dependencies" }, { "type": "chore", "scope": "data", "section": "Data" }, diff --git a/reports/api/brazilian-utils.api.md b/reports/api/brazilian-utils.api.md new file mode 100644 index 00000000..58601ba1 --- /dev/null +++ b/reports/api/brazilian-utils.api.md @@ -0,0 +1,1113 @@ +## API Report File for "@brazilian-utils/brazilian-utils" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +// @public +export const addBusinessDays: (date: Date, amount: number, options?: BusinessDayOptions) => Date | null; + +// @public +export type AddressInfo = { + cep: string; + state: string; + city: string; + neighborhood: string; + street: string; +}; + +// @public +export type AreaCodeInfo = { + areaCode: number; + stateCode: StateCode; + stateName: StateName; + regionCode: State["regionCode"]; + regionName: State["regionName"]; + stateCodes: StateCode[]; +}; + +// @public +export type Bank = { + code: string; + ispb: string; + name: string; +}; + +// @public +export type BoletoInfo = { + amount: number; + expirationDate: Date | null; + bankCode: string; + type?: "arrecadacao"; + segment?: number; + value?: number; + hasEffectiveValue?: boolean; +}; + +// @public +export type BusinessDayOptions = { + stateCode?: StateCode; + includeOptional?: boolean; +}; + +// @public +export const capitalize: (value: string, options?: CapitalizeOptions) => string; + +// @public +export type CapitalizeOptions = { + lowerCaseWords?: string[]; + upperCaseWords?: string[]; +}; + +// @public +export type Cbo = { + code: string; + description: string; +}; + +// @public +export type CepAddressInfo = { + cep: string; + logradouro: string; + complemento: string; + unidade?: string; + bairro: string; + localidade: string; + uf: string; + estado?: string; + regiao?: string; + ibge?: string; + gia?: string; + ddd?: string; + siafi?: string; +}; + +// @public +export type CepProvider = "viacep" | "widenet" | "brasilapi"; + +// @public +export type CertidaoInfo = { + registryCns: string; + acervo: string; + service: string; + year: number; + type: CertidaoType; + typeCode: number; + book: string; + page: string; + term: string; + checkDigits: string; +}; + +// @public +export type CertidaoType = "birth" | "marriage" | "religious-marriage" | "death" | "stillbirth" | "banns" | "other" | "emancipation" | "interdiction"; + +// @public +export type Cfop = { + code: string; + description: string; +}; + +// @public +export type Cnae = { + code: string; + description: string; +}; + +// @public +export const convertCurrencyToWords: (value: number) => string; + +// @public +export const convertDateToWords: (value: Date | string, options?: ConvertDateToWordsOptions) => string; + +// @public +export type ConvertDateToWordsOptions = { + style?: "full" | "month"; + weekday?: boolean; +}; + +// @public +export const convertLicensePlateToMercosul: (value: string) => string; + +// @public +export const convertNumberToWords: (value: number, options?: ConvertNumberToWordsOptions) => string; + +// @public +export type ConvertNumberToWordsOptions = { + gender?: NumberToWordsGender; +}; + +// @public +export const differenceInBusinessDays: (laterDate: Date, earlierDate: Date, options?: BusinessDayOptions) => number | null; + +// @public +export const formatBoleto: (value: string | number, options?: FormatBoletoOptions) => string; + +// @public +export type FormatBoletoOptions = { + pad?: boolean; +}; + +// @public +export const formatCaepf: (value: string | number, options?: FormatCaepfOptions) => string; + +// @public +export type FormatCaepfOptions = { + pad?: boolean; +}; + +// @public +export const formatCei: (value: string | number, options?: FormatCeiOptions) => string; + +// @public +export type FormatCeiOptions = { + pad?: boolean; +}; + +// @public @deprecated +export const formatCEP: typeof formatCep; + +// @public +export const formatCep: (value: string | number, options?: FormatCepOptions) => string; + +// @public +export type FormatCepOptions = { + pad?: boolean; +}; + +// @public +export const formatCertidao: (value: string | number, options?: FormatCertidaoOptions) => string; + +// @public +export type FormatCertidaoOptions = { + pad?: boolean; +}; + +// @public +export const formatCnae: (value: string | number, options?: FormatCnaeOptions) => string; + +// @public +export type FormatCnaeOptions = { + pad?: boolean; +}; + +// @public +export const formatCnh: (value: string | number, options?: FormatCnhOptions) => string; + +// @public +export type FormatCnhOptions = { + pad?: boolean; +}; + +// @public +export const formatCno: (value: string | number, options?: FormatCnoOptions) => string; + +// @public +export type FormatCnoOptions = { + pad?: boolean; +}; + +// @public @deprecated +export const formatCNPJ: typeof formatCnpj; + +// @public +export const formatCnpj: (value: string | number, options?: FormatCnpjOptions) => string; + +// @public +export type FormatCnpjOptions = { + pad?: boolean; + version?: 1 | 2; + obfuscate?: boolean; +}; + +// @public +export const formatCns: (value: string | number, options?: FormatCnsOptions) => string; + +// @public +export type FormatCnsOptions = { + pad?: boolean; +}; + +// @public @deprecated +export const formatCPF: typeof formatCpf; + +// @public +export const formatCpf: (value: string | number, options?: FormatCpfOptions) => string; + +// @public +export type FormatCpfOptions = { + pad?: boolean; + obfuscate?: boolean; +}; + +// @public +export const formatCurrency: (value: string | number, options?: FormatCurrencyOptions) => string; + +// @public +export type FormatCurrencyOptions = { + symbol?: boolean; + precision?: number; +}; + +// @public +export const formatIban: (value: string) => string; + +// @public +export const formatLegalNature: (value: string | number, options?: FormatLegalNatureOptions) => string; + +// @public +export type FormatLegalNatureOptions = { + pad?: boolean; +}; + +// @public +export const formatLicensePlate: (value: string) => string; + +// @public +export const formatNcm: (value: string | number, options?: FormatNcmOptions) => string; + +// @public +export type FormatNcmOptions = { + pad?: boolean; +}; + +// @public +export const formatNfeKey: (value: string, options?: FormatNfeKeyOptions) => string; + +// @public +export type FormatNfeKeyOptions = { + pad?: boolean; +}; + +// @public +export const formatPassport: (passport: string) => string; + +// @public +export const formatPhone: (value: string | number, options?: FormatPhoneOptions) => string; + +// @public +export type FormatPhoneOptions = { + mask?: PhoneMask; +}; + +// @public +export const formatPis: (value: string | number, options?: FormatPisOptions) => string; + +// @public +export type FormatPisOptions = { + pad?: boolean; +}; + +// @public +export const formatProcessoJuridico: (value: string | number, options?: FormatProcessoJuridicoOptions) => string; + +// @public +export type FormatProcessoJuridicoOptions = { + pad?: boolean; +}; + +// @public +export const formatVoterId: (value: string | number) => string; + +// @public +export const generateBoleto: (options?: GenerateBoletoOptions) => string; + +// @public +export type GenerateBoletoOptions = { + type?: "bancario" | "arrecadacao"; +}; + +// @public +export const generateCep: () => string; + +// @public +export const generateCnh: () => string; + +// @public @deprecated +export const generateCNPJ: typeof generateCnpj; + +// @public +export const generateCnpj: (versionOrOptions?: 1 | 2 | GenerateCnpjOptions) => string; + +// @public +export type GenerateCnpjOptions = { + version?: 1 | 2; + branch?: number; +}; + +// @public @deprecated +export const generateCPF: typeof generateCpf; + +// @public +export const generateCpf: (state?: StateCode) => string; + +// @public +export const generateLegalNature: () => string; + +// @public +export const generateLicensePlate: (format?: GenerateLicensePlateFormat) => string; + +// @public +export type GenerateLicensePlateFormat = LicensePlateFormat; + +// @public +export const generatePassport: () => string; + +// @public +export const generatePhone: (type?: GeneratePhoneType) => string; + +// @public +export type GeneratePhoneType = "mobile" | "landline" | "service"; + +// @public +export const generatePis: () => string; + +// @public +export const generatePixPayload: (params: GeneratePixPayloadOptions) => string | null; + +// @public +export type GeneratePixPayloadOptions = { + key?: string; + url?: string; + merchantName: string; + merchantCity: string; + amount?: number; + txid?: string; + description?: string; +}; + +// @public +export const generateProcessoJuridico: (options?: GenerateProcessoJuridicoOptions) => string | null; + +// @public +export type GenerateProcessoJuridicoOptions = { + year?: number; + court?: number; +}; + +// @public +export const generateRenavam: () => string; + +// @public +export const generateVoterId: (state?: StateCode | "ZZ") => string; + +// @public +export const getAddressInfoByCep: (cep: string | number, options?: GetAddressInfoByCepOptions) => Promise; + +// @public +export class GetAddressInfoByCepError extends Error { + constructor(message: string); +} + +// @public +export class GetAddressInfoByCepNotFoundError extends GetAddressInfoByCepError { + constructor(message: string); +} + +// @public +export type GetAddressInfoByCepOptions = { + providers?: CepProvider[]; +}; + +// @public +export class GetAddressInfoByCepServiceError extends GetAddressInfoByCepError { + constructor(message: string); +} + +// @public +export class GetAddressInfoByCepValidationError extends GetAddressInfoByCepError { + constructor(message: string); +} + +// @public +export const getAreaCodeInfo: (areaCode: string | number) => AreaCodeInfo | null; + +// @public +export const getAreaCodesByState: (stateCode: string) => number[]; + +// @public +export const getBankByCode: (code: string | number) => Bank | null; + +// @public +export const getBankByIspb: (value: string | number) => Bank | null; + +// @public +export const getBanks: () => Bank[]; + +// @public +export const getBoletoInfo: (value: string, options?: GetBoletoInfoOptions) => BoletoInfo | null; + +// @public +export type GetBoletoInfoOptions = { + referenceDate?: Date; +}; + +// @public +export const getCbo: (value: string | number) => Cbo | null; + +// @public +export const getCepInfoByAddress: (params: GetCepInfoByAddressOptions) => Promise; + +// @public +export class GetCepInfoByAddressError extends Error { + constructor(message: string); +} + +// @public +export class GetCepInfoByAddressNotFoundError extends GetCepInfoByAddressError { + constructor(message: string); +} + +// @public +export type GetCepInfoByAddressOptions = { + federalUnit: string; + city: string; + street: string; +}; + +// @public +export class GetCepInfoByAddressValidationError extends GetCepInfoByAddressError { + constructor(message: string); +} + +// @public +export const getCertidaoInfo: (value: string) => CertidaoInfo | null; + +// @public +export const getCfop: (value: string | number) => Cfop | null; + +// @public @deprecated +export const getCities: (state?: StateCode) => string[]; + +// @public +export const getCnae: (value: string | number) => Cnae | null; + +// @public +export const getFormatLicensePlate: (value: string) => LicensePlateFormat | null; + +// @public +export function getHolidays(year: number): Holiday[]; + +// @public +export function getHolidays(options: GetHolidaysOptions): Holiday[]; + +// @public +export type GetHolidaysOptions = { + year: number; + stateCode?: StateCode; +}; + +// @public +export const getIbanInfo: (value: string) => IbanInfo | null; + +// @public +export const getLegalNature: (value: string | number) => LegalNature | null; + +// @public +export const getLegalNatures: () => Record; + +// @public +export const getLegalNaturesByCategory: (category: string | number) => LegalNature[]; + +// @public +export const getMunicipalities: (stateCode?: StateCode) => Municipality[]; + +// @public @deprecated +export function getMunicipality(options: GetMunicipalityByCodeOptions): Promise<[string, string] | null>; + +// @public @deprecated +export function getMunicipality(options: GetMunicipalityByNameOptions): Promise; + +// @public @deprecated +export function getMunicipality(options: GetMunicipalityOptions): Promise<[string, string] | string | null>; + +// @public +export const getMunicipalityByCode: (code: string | number) => Municipality | null; + +// @public +export type GetMunicipalityByCodeOptions = { + code: string | number; +}; + +// @public +export type GetMunicipalityByNameOptions = { + municipalityName: string; + uf: string; +}; + +// @public +export type GetMunicipalityOptions = GetMunicipalityByCodeOptions | GetMunicipalityByNameOptions; + +// @public +export const getNfeKeyInfo: (value: string) => NfeKeyInfo | null; + +// @public +export const getPixKeyInfo: (value: string) => PixKeyInfo | null; + +// @public +export const getPixPayloadInfo: (value: string) => PixPayloadInfo | null; + +// @public +export const getStateByIbgeCode: (code: string | number) => State | null; + +// @public +export const getStateCodeByName: (name: string) => StateCode | null; + +// @public +export const getStateNameByCode: (code: string) => StateName | null; + +// @public +export const getStates: () => State[]; + +// @public +export const getTimezoneByState: (stateCode: string) => string | null; + +// @public +export type Holiday = { + name: string; + date: Date; + type: HolidayType; +}; + +// @public +export type HolidayType = "national" | "state" | "optional" | "religious"; + +// @public +export type IbanInfo = { + countryCode: "BR"; + checkDigits: string; + bankIspb: string; + branch: string; + account: string; + accountType: string; + owner: string; +}; + +// @public +export const isBusinessDay: (value: Date, options?: BusinessDayOptions) => boolean; + +// @public +export const isHoliday: (options?: IsHolidayOptions) => boolean; + +// @public +export type IsHolidayOptions = { + targetDate: Date; + stateCode?: StateCode; +}; + +// @public +export const isValidBankAccount: (params: IsValidBankAccountOptions) => boolean; + +// @public +export type IsValidBankAccountOptions = { + bankCode: string; + agency: string; + account: string; + digit: string; +}; + +// @public @deprecated +export type IsValidBankAccountParams = IsValidBankAccountOptions; + +// @public +export const isValidBoleto: (value: string) => boolean; + +// @public +export const isValidCaepf: (value: string | number) => boolean; + +// @public +export const isValidCbo: (value: string | number) => boolean; + +// @public +export const isValidCei: (value: string | number) => boolean; + +// @public @deprecated +export const isValidCEP: typeof isValidCep; + +// @public +export const isValidCep: (cep: string | number) => boolean; + +// @public +export const isValidCertidao: (value: string, options?: IsValidCertidaoOptions) => boolean; + +// @public +export type IsValidCertidaoOptions = { + accept?: CertidaoType[]; +}; + +// @public +export const isValidCfop: (value: string | number) => boolean; + +// @public +export const isValidCnae: (value: string | number) => boolean; + +// @public +export const isValidCnh: (value: string) => boolean; + +// @public +export const isValidCno: (value: string | number) => boolean; + +// @public @deprecated +export const isValidCNPJ: typeof isValidCnpj; + +// @public +export const isValidCnpj: (cnpj: string, options?: IsValidCnpjOptions) => boolean; + +// @public +export type IsValidCnpjOptions = { + version?: 1 | 2; +}; + +// @public +export const isValidCns: (value: string | number) => boolean; + +// @public @deprecated +export const isValidCPF: typeof isValidCpf; + +// @public +export const isValidCpf: (cpf: string) => boolean; + +// @public +export const isValidCreditCard: (value: string | number) => boolean; + +// @public +export const isValidCsosn: (value: string | number) => boolean; + +// @public +export const isValidCst: (value: string | number, options?: IsValidCstOptions) => boolean; + +// @public +export type IsValidCstOptions = { + tax?: "icms" | "ipi" | "pis" | "cofins"; +}; + +// @public +export const isValidEmail: (value: string) => boolean; + +// @public +export const isValidIban: (value: string) => boolean; + +// @public @deprecated +export const isValidIE: typeof isValidIe; + +// @public +export const isValidIe: (stateCode: StateCode, ie: string) => boolean; + +// @public +export const isValidLandlinePhone: (value: string) => boolean; + +// @public +export const isValidLegalNature: (code: string) => boolean; + +// @public +export const isValidLicensePlate: (value: string) => boolean; + +// @public +export const isValidMobilePhone: (value: string, options?: IsValidMobilePhoneOptions) => boolean; + +// @public +export type IsValidMobilePhoneOptions = { + version?: PhoneVersion; +}; + +// @public +export const isValidNcm: (value: string | number) => boolean; + +// @public +export const isValidNfeKey: (value: string) => boolean; + +// @public +export const isValidPassport: (passport: string | number) => boolean; + +// @public +export const isValidPhone: (value: string, options?: IsValidPhoneOptions) => boolean; + +// @public +export type IsValidPhoneOptions = { + version?: PhoneVersion; + accept?: PhoneType[]; +}; + +// @public @deprecated +export const isValidPIS: typeof isValidPis; + +// @public +export const isValidPis: (pis: string) => boolean; + +// @public +export const isValidPixKey: (value: string, options?: IsValidPixKeyOptions) => boolean; + +// @public +export type IsValidPixKeyOptions = { + accept?: PixKeyType[]; +}; + +// @public +export const isValidPixPayload: (value: string) => boolean; + +// @public +export const isValidProcessoJuridico: (value: string) => boolean; + +// @public +export const isValidRegistroProfissional: (params: IsValidRegistroProfissionalOptions) => boolean; + +// @public +export type IsValidRegistroProfissionalOptions = { + value: string; + council: RegistroProfissionalCouncil; + stateCode?: StateCode; +}; + +// @public +export const isValidRenavam: (renavam: string | number) => boolean; + +// @public +export const isValidServicePhone: (value: string) => boolean; + +// @public +export const isValidVin: (value: string) => boolean; + +// @public +export const isValidVoterId: (value: string) => boolean; + +// @public +export type LegalNature = { + code: string; + description: string; + category: LegalNatureCategory; +}; + +// @public +export type LegalNatureCategory = { + code: "1" | "2" | "3" | "4" | "5"; + description: string; +}; + +// @public +export type LicensePlateFormat = "LLLNNNN" | "LLLNLNN"; + +// @public +export type Municipality = { + code: string; + name: string; + stateCode: StateCode; +}; + +// @public +export type NfeKeyInfo = { + stateCode: StateCode; + year: number; + month: number; + taxId: string; + model: NfeKeyModel; + series: number; + number: number; + emissionType: number; + authorizationSite?: number; + code: string; + checkDigit: number; +}; + +// @public +export type NfeKeyModel = "55" | "57" | "58" | "62" | "63" | "64" | "65" | "66" | "67"; + +// @public +export type NumberToWordsGender = "masculine" | "feminine"; + +// @public +export const parseBoleto: (value: string | number) => string; + +// @public +export const parseCaepf: (value: string | number) => string; + +// @public +export const parseCbo: (value: string | number) => string; + +// @public +export const parseCei: (value: string | number) => string; + +// @public +export const parseCep: (value: string | number) => string; + +// @public +export const parseCertidao: (value: string | number) => string; + +// @public +export const parseCfop: (value: string | number) => string; + +// @public +export const parseCnae: (value: string | number) => string; + +// @public +export const parseCnh: (value: string | number) => string; + +// @public +export const parseCno: (value: string | number) => string; + +// @public +export const parseCnpj: (value: string | number, options?: ParseCnpjOptions) => string; + +// @public +export type ParseCnpjOptions = Pick; + +// @public +export const parseCns: (value: string | number) => string; + +// @public +export const parseCpf: (value: string | number) => string; + +// @public +export const parseCurrency: (value: string, options?: ParseCurrencyOptions) => number; + +// @public +export type ParseCurrencyOptions = { + precision?: number; +}; + +// @public +export const parseIban: (value: string | number) => string; + +// @public +export const parseLegalNature: (value: string | number) => string; + +// @public +export const parseLicensePlate: (value: string) => string; + +// @public +export const parseNcm: (value: string | number) => string; + +// @public +export const parseNfeKey: (value: string | number) => string; + +// @public +export const parsePassport: (passport: string) => string; + +// @public +export const parsePhone: (value: string | number) => string; + +// @public +export const parsePis: (value: string | number) => string; + +// @public +export const parseProcessoJuridico: (value: string | number) => string; + +// @public +export const parseVoterId: (value: string | number) => string; + +// @public +export type PhoneMask = "auto" | "e164" | "international" | "service" | "sn" | "nanp"; + +// @public +export type PhoneType = "mobile" | "landline" | "service"; + +// @public +export type PhoneVersion = 1 | 2; + +// @public +export type PixKeyInfo = { + type: PixKeyType; + value: string; +}; + +// @public +export type PixKeyType = "cpf" | "cnpj" | "email" | "phone" | "evp"; + +// @public +export type PixPayloadInfo = { + key?: string; + url?: string; + description?: string; + withdrawalFacilitator?: string; + merchantName: string; + merchantCity: string; + amount?: number; + txid?: string; + pointOfInitiation: PixPointOfInitiation; +}; + +// @public +export type PixPointOfInitiation = "static" | "dynamic"; + +// @public +export type RegistroProfissionalCouncil = "OAB" | "CRM" | "CRO" | "CRP" | "CRC"; + +// @public +export const removeAccents: (value: string) => string; + +// @public +export type State = { + readonly code: "AC"; + readonly name: "Acre"; + readonly regionCode: "N"; + readonly regionName: "Norte"; + readonly ibgeCode: 12; +} | { + readonly code: "AL"; + readonly name: "Alagoas"; + readonly regionCode: "NE"; + readonly regionName: "Nordeste"; + readonly ibgeCode: 27; +} | { + readonly code: "AP"; + readonly name: "Amapá"; + readonly regionCode: "N"; + readonly regionName: "Norte"; + readonly ibgeCode: 16; +} | { + readonly code: "AM"; + readonly name: "Amazonas"; + readonly regionCode: "N"; + readonly regionName: "Norte"; + readonly ibgeCode: 13; +} | { + readonly code: "BA"; + readonly name: "Bahia"; + readonly regionCode: "NE"; + readonly regionName: "Nordeste"; + readonly ibgeCode: 29; +} | { + readonly code: "CE"; + readonly name: "Ceará"; + readonly regionCode: "NE"; + readonly regionName: "Nordeste"; + readonly ibgeCode: 23; +} | { + readonly code: "DF"; + readonly name: "Distrito Federal"; + readonly regionCode: "CO"; + readonly regionName: "Centro-Oeste"; + readonly ibgeCode: 53; +} | { + readonly code: "ES"; + readonly name: "Espírito Santo"; + readonly regionCode: "SE"; + readonly regionName: "Sudeste"; + readonly ibgeCode: 32; +} | { + readonly code: "GO"; + readonly name: "Goiás"; + readonly regionCode: "CO"; + readonly regionName: "Centro-Oeste"; + readonly ibgeCode: 52; +} | { + readonly code: "MA"; + readonly name: "Maranhão"; + readonly regionCode: "NE"; + readonly regionName: "Nordeste"; + readonly ibgeCode: 21; +} | { + readonly code: "MT"; + readonly name: "Mato Grosso"; + readonly regionCode: "CO"; + readonly regionName: "Centro-Oeste"; + readonly ibgeCode: 51; +} | { + readonly code: "MS"; + readonly name: "Mato Grosso do Sul"; + readonly regionCode: "CO"; + readonly regionName: "Centro-Oeste"; + readonly ibgeCode: 50; +} | { + readonly code: "MG"; + readonly name: "Minas Gerais"; + readonly regionCode: "SE"; + readonly regionName: "Sudeste"; + readonly ibgeCode: 31; +} | { + readonly code: "PA"; + readonly name: "Pará"; + readonly regionCode: "N"; + readonly regionName: "Norte"; + readonly ibgeCode: 15; +} | { + readonly code: "PB"; + readonly name: "Paraíba"; + readonly regionCode: "NE"; + readonly regionName: "Nordeste"; + readonly ibgeCode: 25; +} | { + readonly code: "PR"; + readonly name: "Paraná"; + readonly regionCode: "S"; + readonly regionName: "Sul"; + readonly ibgeCode: 41; +} | { + readonly code: "PE"; + readonly name: "Pernambuco"; + readonly regionCode: "NE"; + readonly regionName: "Nordeste"; + readonly ibgeCode: 26; +} | { + readonly code: "PI"; + readonly name: "Piauí"; + readonly regionCode: "NE"; + readonly regionName: "Nordeste"; + readonly ibgeCode: 22; +} | { + readonly code: "RJ"; + readonly name: "Rio de Janeiro"; + readonly regionCode: "SE"; + readonly regionName: "Sudeste"; + readonly ibgeCode: 33; +} | { + readonly code: "RN"; + readonly name: "Rio Grande do Norte"; + readonly regionCode: "NE"; + readonly regionName: "Nordeste"; + readonly ibgeCode: 24; +} | { + readonly code: "RS"; + readonly name: "Rio Grande do Sul"; + readonly regionCode: "S"; + readonly regionName: "Sul"; + readonly ibgeCode: 43; +} | { + readonly code: "RO"; + readonly name: "Rondônia"; + readonly regionCode: "N"; + readonly regionName: "Norte"; + readonly ibgeCode: 11; +} | { + readonly code: "RR"; + readonly name: "Roraima"; + readonly regionCode: "N"; + readonly regionName: "Norte"; + readonly ibgeCode: 14; +} | { + readonly code: "SC"; + readonly name: "Santa Catarina"; + readonly regionCode: "S"; + readonly regionName: "Sul"; + readonly ibgeCode: 42; +} | { + readonly code: "SP"; + readonly name: "São Paulo"; + readonly regionCode: "SE"; + readonly regionName: "Sudeste"; + readonly ibgeCode: 35; +} | { + readonly code: "SE"; + readonly name: "Sergipe"; + readonly regionCode: "NE"; + readonly regionName: "Nordeste"; + readonly ibgeCode: 28; +} | { + readonly code: "TO"; + readonly name: "Tocantins"; + readonly regionCode: "N"; + readonly regionName: "Norte"; + readonly ibgeCode: 17; +}; + +// @public +export type StateCode = State["code"]; + +// @public +export type StateName = State["name"]; + +// @public +export const subBusinessDays: (date: Date, amount: number, options?: BusinessDayOptions) => Date | null; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/scripts/banks.ts b/scripts/banks.ts index 31674705..8de6a6ce 100644 --- a/scripts/banks.ts +++ b/scripts/banks.ts @@ -120,7 +120,7 @@ const fetchFromBrasilApi = async (): Promise => { for (const entry of json) { if (!isBrasilApiBank(entry) || typeof entry.code !== "number") continue; - if (!Number.isInteger(entry.code) || entry.code < 0 || entry.code > 999) continue; + if (!Number.isInteger(entry.code) || entry.code <= 0 || entry.code > 999) continue; const ispb = entry.ispb; @@ -165,15 +165,12 @@ const main = async (): Promise => { throw new Error("Refusing to write an empty bank dataset"); } - console.log(`Generated ${sorted.length} banks from ${source}`); - - await writeFile( - resolve(scriptsDir, "..", "./src/_internals/constants/banks.ts"), - `/** + const banksPath = resolve(scriptsDir, "..", "./src/_internals/constants/banks.ts"); + const banksFile = `/** * Brazilian STR (Sistema de Transferência de Reservas) participants that have a compensation * code (commonly known as COMPE), published by Banco Central do Brasil. Generated by * \`scripts/banks.ts\`. - * @see ${BACEN_CSV_URL} + * @see Official: ${BACEN_CSV_URL} */ export type Bank = { /** Compensation code (COMPE), 3 digits, zero-padded. */ @@ -184,22 +181,23 @@ export type Bank = { name: string; }; -export const BANKS: Bank[] = ${JSON.stringify(sorted)};`, - ); +export const BANKS: Bank[] = ${JSON.stringify(sorted)};`; const compeCodes = sorted.map((bank) => bank.code).join(""); const constantsPath = resolve(scriptsDir, "..", "./src/is-valid-bank-account/constants.ts"); const constants = await readFile(constantsPath, "utf8"); const literal = (compeCodes.match(/.{1,90}/g) ?? []).map((chunk) => `\t"${chunk}"`).join(" +\n"); - const updated = constants.replace( - /export const COMPE_CODES =\n(?:\t"\d*" \+\n)*\t"\d*";/, - `export const COMPE_CODES =\n${literal};`, - ); + const compeCodesPattern = /export const COMPE_CODES =\n(?:\t"\d*" \+\n)*\t"\d*";/; - if (updated === constants) { + if (!compeCodesPattern.test(constants)) { throw new Error("COMPE_CODES literal not found in src/is-valid-bank-account/constants.ts"); } + const updated = constants.replace(compeCodesPattern, `export const COMPE_CODES =\n${literal};`); + + console.log(`Generated ${sorted.length} banks from ${source}`); + + await writeFile(banksPath, banksFile); await writeFile(constantsPath, updated); console.log(`Updated COMPE_CODES with ${sorted.length} codes`); }; diff --git a/scripts/cfop.ts b/scripts/cfop.ts index bc616d46..aec80377 100644 --- a/scripts/cfop.ts +++ b/scripts/cfop.ts @@ -15,6 +15,25 @@ const scriptsDir = import.meta.dirname; */ const CURRENT_TEXT_PARAGRAPH_REGEX = /

([^<]*)<\/p>/g; +/** + * Smallest number of operable codes a complete annex yields. The consolidated text carries 619 + * today and CONFAZ only adds or replaces codes, so a result far below it means the markup changed + * and the paragraph pattern above stopped matching, not that codes were revoked. + */ +const MINIMUM_OPERABLE_CODES = 600; + +const HTML_ENTITIES: Record = { + "&": "&", + "<": "<", + ">": ">", + """: '"', + "'": "'", + " ": " ", +}; + +const decodeEntities = (text: string): string => + text.replaceAll(/&(?:amp|lt|gt|quot|#39|nbsp);/g, (entity) => HTML_ENTITIES[entity] ?? entity); + /** A code line, e.g. `1.101 - Compra para industrialização ou produção rural.`. */ const CODE_LINE_REGEX = /^(\d)\.(\d{3})\s*[-–]\s*(.+)$/; @@ -39,7 +58,9 @@ const TRAILING_PUNCTUATION_REGEX = /[.\s]+$/; */ const parseAnnex = (html: string): Record => { const paragraphs = [...html.matchAll(CURRENT_TEXT_PARAGRAPH_REGEX)].map((match) => - (match[1] ?? "").replaceAll(/\s+/g, " ").trim(), + decodeEntities(match[1] ?? "") + .replaceAll(/\s+/g, " ") + .trim(), ); const data: Record = {}; @@ -75,8 +96,12 @@ const main = async (): Promise => { async (response) => { const data = parseAnnex(await response.text()); - if (Object.keys(data).length === 0) { - throw new Error("CFOP annex page holds no operable code"); + const count = Object.keys(data).length; + + if (count < MINIMUM_OPERABLE_CODES) { + throw new Error( + `CFOP annex page yielded ${count} operable codes, below the ${MINIMUM_OPERABLE_CODES} a complete annex holds; the markup probably changed`, + ); } return data; @@ -103,6 +128,8 @@ const main = async (): Promise => { * Anexo II of Convênio SINIEF s/nº 1970, the CFOP table in force. * @see Official: https://www.confaz.fazenda.gov.br/legislacao/ajustes/sinief/cvsn_70 * Convênio SINIEF s/nº 1970, the consolidated text the annex belongs to. + * @see Official: https://www.confaz.fazenda.gov.br/legislacao/ajustes/2025/AJ039_25 + * Ajuste SINIEF 39/25, the last amendment the annex carries (CFOP 7.667, from 01.02.26). * @see Official: https://www.confaz.fazenda.gov.br/legislacao/ajustes/2001/AJ_007_01 * Ajuste SINIEF 07/01, the historical text that gave the CFOP its 4 digit form. */ diff --git a/scripts/cnae.ts b/scripts/cnae.ts index 864ecd4e..98cbfa6a 100644 --- a/scripts/cnae.ts +++ b/scripts/cnae.ts @@ -46,13 +46,23 @@ const main = async (): Promise => { await writeFile( resolve(scriptsDir, "..", "./src/_internals/constants/cnae.ts"), `/** - * CNAE 2.3 (Classificação Nacional de Atividades Econômicas) subclasses, indexed by the - * raw 7 digit code, mapping to the official subclass description. + * CNAE-Subclasses 2.3 (Classificação Nacional de Atividades Econômicas) subclasses, indexed by + * the raw 7 digit code, mapping to the official subclass description. + * + * 2.3 is the current subclass revision of CNAE 2.0: CONCLA's own CNAE browser lists it as + * "CNAE-Subclasses 2.3" under "CNAE 2.0 (Res 02/2010)" and tells anyone opening an older table + * that the "Versões atuais da CNAE" are "CNAE 2.0 (Res 02/2010)" and "CNAE-Subclasses 2.3". The + * classification's landing page still describes the parent CNAE 2.0 itself ("Base Legal: + * Resolução Concla 01/2006", 1301 subclasses); the 1332 subclasses below are the ones the IBGE + * data service publishes for the 2.3 revision. * * Generated by \`node ./scripts/cnae.ts\`. Do not edit by hand. * * @see Official: https://servicodados.ibge.gov.br/api/v2/cnae/subclasses + * @see Official: https://concla.ibge.gov.br/busca-online-cnae.html + * CONCLA's CNAE search and structure browser, which publishes CNAE-Subclasses 2.3. * @see Official: https://concla.ibge.gov.br/classificacoes/por-tema/atividades-economicas/classificacao-nacional-de-atividades-economicas + * CNAE 2.0, the parent classification the 2.3 subclass revision belongs to. */ export const CNAE_SUBCLASSES: Record = ${JSON.stringify(data)}; diff --git a/scripts/data.ts b/scripts/data.ts index 52c40380..850c569d 100644 --- a/scripts/data.ts +++ b/scripts/data.ts @@ -45,19 +45,13 @@ const results = await Promise.all( generators.map((generator) => run("node", [resolve(scriptsDir, generator)])), ); -if (results.some((result) => result !== 0)) { - process.exit(1); -} - -const formatResult = await run("vp", ["fmt", "--write", ...generatedFiles]); - -if (formatResult !== 0) { - process.exit(1); -} - +// Lint and format before checking the generators, so a failing generator never leaves +// unformatted files behind in the working tree. `vp fmt` runs last because `vp lint --fix` +// rewrites code without reformatting it. const lintResult = await run("vp", ["lint", "--fix", ...generatedFiles]); +const formatResult = await run("vp", ["fmt", "--write", ...generatedFiles]); -if (lintResult !== 0) { +if (results.some((result) => result !== 0) || lintResult !== 0 || formatResult !== 0) { process.exit(1); } diff --git a/scripts/legal-natures.ts b/scripts/legal-natures.ts index c5c40428..13b9deab 100644 --- a/scripts/legal-natures.ts +++ b/scripts/legal-natures.ts @@ -193,6 +193,12 @@ const main = async (): Promise => { Object.entries(LEGACY_LEGAL_NATURE).filter(([code]) => !(code in current)), ); + const legacyCodes = Object.keys(legacy); + + const typoFixedCodes = Object.entries(current) + .filter(([, description]) => Object.values(TYPO_FIXES).includes(description)) + .map(([code]) => code); + await writeFile( resolve(scriptsDir, "..", OUTPUT_PATH), `/** @@ -200,14 +206,26 @@ const main = async (): Promise => { * * Generated by \`node ./scripts/legal-natures.ts\`. Do not edit by hand. * - * @see ${SOURCE_PAGE_URL} - * @see ${SOURCE_URL} + * ${codes.length} of the ${codes.length + legacyCodes.length} entries are the official codes from the CONCLA 2021 table; the other + * ${legacyCodes.length} (${legacyCodes.join(", ")}) are legacy codes kept for 2.3.0 + * compatibility. Separately, and unrelated to those legacy codes, the descriptions of the + * following official codes fix an accent typo of the PDF: ${typoFixedCodes.join(", ")}. + * + * The CONCLA table page sits behind a bot filter and answers HTTP 403 to every non-browser + * client, so it has to be opened in a browser; the detailed structure PDF next to it is served + * normally. + * + * @see Official: ${SOURCE_PAGE_URL} + * @see Official: ${SOURCE_URL} */ export const LEGAL_NATURE: Record = { ${stringifyEntries(current)} ${stringifyEntries(legacy)} }; + +/** Mask characters (hyphen, dot, whitespace) tolerated around a legal nature code. */ +export const MASK_REGEX = /[-.\\s]/g; `, ); }; diff --git a/scripts/llms.ts b/scripts/llms.ts index 22214905..edd5180e 100644 --- a/scripts/llms.ts +++ b/scripts/llms.ts @@ -72,6 +72,23 @@ function firstSentence(paragraph: string): string { return sentence.split(ABBREVIATION_PLACEHOLDER).join(".").trim(); } +const DEPRECATION_MARKER = "**Deprecated:**"; + +/** + * Extracts the `**Deprecated:** ...` sentence of a paragraph, without its markdown bold. The + * description of an entry is its first sentence, and a deprecation notice never is the first + * sentence, so without this it would be dropped from the generated index. + * @param {string} paragraph - The paragraph to read the deprecation notice of. + * @returns {string} The deprecation sentence, or an empty string when the paragraph carries none. + */ +function deprecationSentence(paragraph: string): string { + const markerIndex = paragraph.indexOf(DEPRECATION_MARKER); + + if (markerIndex === -1) return ""; + + return firstSentence(paragraph.slice(markerIndex).replaceAll("**", "")); +} + /** * Parses every `## ` section of `utilities.md` into name/slug/description. * @param {string} utilitiesMd - The full contents of `utilities.md`. @@ -86,15 +103,80 @@ function parseUtilities(utilitiesMd: string): UtilSection[] { const body = section.slice(newlineIndex + 1); const [firstParagraphRaw = ""] = body.split(/\n\s*\n/); const firstParagraph = firstParagraphRaw.trim(); + const description = firstSentence(firstParagraph); + const deprecation = description.includes(DEPRECATION_MARKER) + ? "" + : deprecationSentence(firstParagraph); return { name, slug: slugify(name), - description: firstSentence(firstParagraph), + description: deprecation === "" ? description : `${description} ${deprecation}`, }; }); } +const FENCE_MARKER = "```"; +const SUB_HEADING_PATTERN = /^#{2,3} (.+)$/; +const BACKTICKED_PATTERN = /`([^`]+)`/g; + +/** + * Collects the `##` and `###` headings of a page, in document order and outside code fences, so a + * generated table of contents cannot drift from the page it indexes. + * @param {string} markdown - The Markdown page to read the headings of. + * @returns {string[]} The heading texts, in document order. + */ +function subHeadings(markdown: string): string[] { + let insideFence = false; + + return markdown.split("\n").flatMap((line) => { + if (line.startsWith(FENCE_MARKER)) { + insideFence = !insideFence; + return []; + } + + if (insideFence) return []; + + const heading = SUB_HEADING_PATTERN.exec(line)?.[1]; + + return heading === undefined ? [] : [heading.trim()]; + }); +} + +/** + * Reads the util names listed in the "Bundle size" table of `getting-started.md`, so the summary + * of the dataset-backed utils cannot drift from the table it summarizes. + * @param {string} gettingStartedMd - The full contents of `getting-started.md`. + * @returns {string[]} The util names of the table, in document order. + */ +function parseDatasetUtils(gettingStartedMd: string): string[] { + const section = /\n## Bundle size\n([\s\S]*?)(?=\n## |$)/.exec(gettingStartedMd)?.[1] ?? ""; + + const names: string[] = []; + + for (const row of section.split("\n")) { + if (!row.startsWith("| `")) continue; + + for (const [, name] of (row.split("|")[1] ?? "").matchAll(BACKTICKED_PATTERN)) { + if (name !== undefined) names.push(name); + } + } + + return names; +} + +/** + * Joins names into an English list, e.g. "`a`, `b` and `c`". + * @param {string[]} names - The names to join, in order. + * @returns {string} The names, backticked and comma-separated, with "and" before the last one. + */ +function joinNames(names: string[]): string { + const quoted = names.map((name) => `\`${name}\``); + const last = quoted.at(-1) ?? ""; + + return quoted.length < 2 ? last : `${quoted.slice(0, -1).join(", ")} and ${last}`; +} + const PREFIX_GROUPS: { title: string; test: (name: string) => boolean }[] = [ { title: "Validators (isValid*)", test: (name) => name.startsWith("isValid") }, { title: "Formatters (format*)", test: (name) => name.startsWith("format") }, @@ -132,7 +214,7 @@ function utilLink(util: UtilSection): string { return `- [${util.name}](${SITE}/utilities.md#${util.slug}): ${util.description}`; } -function buildLlmsTxt(utils: UtilSection[]): string { +function buildLlmsTxt(utils: UtilSection[], datasetUtils: string[]): string { const groups = groupUtilities(utils); const groupSections = groups .map((group) => `## ${group.title}\n\n${group.utils.map(utilLink).join("\n")}`) @@ -150,7 +232,7 @@ Install with \`npm install --save @brazilian-utils/brazilian-utils\` (also avail import { isValidCpf } from '@brazilian-utils/brazilian-utils'; \`\`\` -Every util is also available as its own subpath for lazy-loading/code-splitting, \`@brazilian-utils/brazilian-utils/\` (kebab-case of the function name, e.g. \`isValidCpf\` maps to \`is-valid-cpf\`) - most useful for \`getCities\`, the one util that embeds a large dataset: +Every util is also available as its own subpath for lazy-loading/code-splitting, \`@brazilian-utils/brazilian-utils/\` (kebab-case of the function name, e.g. \`isValidCpf\` maps to \`is-valid-cpf\`) - most useful for the utils that embed an official dataset (${joinNames(datasetUtils)}): \`\`\`javascript const { getCities } = await import('@brazilian-utils/brazilian-utils/get-cities'); @@ -160,7 +242,7 @@ const { getCities } = await import('@brazilian-utils/brazilian-utils/get-cities' - [Getting started](${SITE}/getting-started.md): installation, runtime support, usage and bundle size/subpath imports - [Utilities](${SITE}/utilities.md): full English reference, one section per function, with signatures and examples -- [Bundle size](${SITE}/getting-started.md#bundle-size): tree-shaking behavior and the \`getCities\`/subpath-import exception +- [Bundle size](${SITE}/getting-started.md#bundle-size): tree-shaking behavior and the dataset-backed utils that are worth a subpath import ${groupSections} @@ -210,9 +292,7 @@ function buildLlmsFullTxt( ): string { const toc = [ "- [Getting Started](#getting-started)", - ...["Installation", "Runtime support", "Usage", "Bundle size"].map( - (heading) => ` - [${heading}](#${slugify(heading)})`, - ), + ...subHeadings(gettingStartedMd).map((heading) => ` - [${heading}](#${slugify(heading)})`), "- [Utilities](#utilities)", ...utils.map((util) => ` - [${util.name}](#${util.slug})`), ].join("\n"); @@ -239,7 +319,10 @@ function main(): void { const utilitiesMd = readFileSync(join(DOCS_DIR, "utilities.md"), "utf8"); const utils = parseUtilities(utilitiesMd); - writeFileSync(join(DOCS_DIR, "llms.txt"), buildLlmsTxt(utils)); + writeFileSync( + join(DOCS_DIR, "llms.txt"), + buildLlmsTxt(utils, parseDatasetUtils(gettingStartedMd)), + ); writeFileSync( join(DOCS_DIR, "llms-full.txt"), buildLlmsFullTxt(gettingStartedMd, utilitiesMd, utils), diff --git a/scripts/ncm.ts b/scripts/ncm.ts index 8f8bca5c..26687ad5 100644 --- a/scripts/ncm.ts +++ b/scripts/ncm.ts @@ -34,19 +34,43 @@ const isNcmResponse = (value: unknown): value is NcmResponse => Array.isArray(value.Nomenclaturas) && value.Nomenclaturas.every((entry) => isNcmEntry(entry)); +const BR_DATE_REGEX = /^(\d{2})\/(\d{2})\/(\d{4})$/; + /** * Parses a Siscomex `dd/mm/yyyy` date into a `Date` at UTC midnight. * + * The string must have the exact `dd/mm/yyyy` shape and name a day that exists: `Date.UTC` + * rolls impossible dates over (`31/02/2026` would become 3 March 2026) and would silently + * widen the in-force window, so the parsed components are compared back against the date. + * An invalid `Date` is returned otherwise, which makes every comparison in `isInForce` false. + * * @param {string} date - A date string in `dd/mm/yyyy` format. - * @returns {Date} The parsed date. + * @returns {Date} The parsed date, or an invalid `Date`. */ const parseBrDate = (date: string): Date => { - const [day, month, year] = date.split("/").map(Number); - return new Date(Date.UTC(year, month - 1, day)); + const match = BR_DATE_REGEX.exec(date); + + if (match === null) return new Date(Number.NaN); + + const day = Number(match[1]); + const month = Number(match[2]); + const year = Number(match[3]); + const parsed = new Date(Date.UTC(year, month - 1, day)); + + if ( + parsed.getUTCFullYear() !== year || + parsed.getUTCMonth() + 1 !== month || + parsed.getUTCDate() !== day + ) { + return new Date(Number.NaN); + } + + return parsed; }; /** - * Whether `today` falls within `[Data_Inicio, Data_Fim]` (both inclusive). + * Whether `today` falls within `[Data_Inicio, Data_Fim]` (both inclusive). An entry whose + * boundaries are not both valid `dd/mm/yyyy` dates is treated as not in force. * * @param {NcmEntry} entry - The Siscomex NCM entry to check. * @param {Date} today - The reference date. @@ -70,9 +94,8 @@ const main = async (): Promise => { throw new Error("Siscomex NCM payload is not a Nomenclaturas response"); } - const today = new Date( - Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth(), new Date().getUTCDate()), - ); + const now = new Date(); + const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); const codes = json.Nomenclaturas.filter( (entry) => /^[\d.]{10}$/.test(entry.Codigo) && isInForce(entry, today), diff --git a/scripts/tree-shaking.ts b/scripts/tree-shaking.ts index cb8e2a58..ccf615e0 100644 --- a/scripts/tree-shaking.ts +++ b/scripts/tree-shaking.ts @@ -47,6 +47,9 @@ import { build } from "esbuild"; const rootDir = resolve(import.meta.dirname, ".."); const packageName = "@brazilian-utils/brazilian-utils"; +/** Exit code of a comparison that could not be carried out (missing build, unreadable base). */ +const COMPARISON_ERROR_EXIT_CODE = 2; + const CONCURRENCY = 16; const FULL_IMPORT_KEY = "__full__"; @@ -250,7 +253,7 @@ const measureExports = async ( const distEntry = resolve(packageRoot, "dist/brazilian-utils.js"); if (!existsSync(distEntry)) { console.error(`Missing ${distEntry}. Run \`npm run build\` first.`); - process.exit(1); + process.exit(COMPARISON_ERROR_EXIT_CODE); } const { testable, aliasOf } = await loadExports(distEntry); @@ -579,8 +582,6 @@ const readSnapshot = async (path: string): Promise => { return parsed; }; -const COMPARISON_ERROR_EXIT_CODE = 2; - const main = async (): Promise => { const args = parseArgs(process.argv.slice(2)); const packageRoot = diff --git a/src/_internals/apply-words-case/apply-words-case.ts b/src/_internals/apply-words-case/apply-words-case.ts deleted file mode 100644 index fddb580a..00000000 --- a/src/_internals/apply-words-case/apply-words-case.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { type WordsCase } from "../number-to-words/number-to-words"; - -/** - * Applies a `WordsCase` to a "por extenso" string already written out in lowercase. - * - * `"sentence"` capitalizes only the first letter; `"upper"` uppercases the whole string with - * `toLocaleUpperCase("pt-BR")`, which keeps accents intact ("três" -> "TRÊS"). Any value other - * than `"sentence"` or `"upper"` (including `"lower"`, `undefined` or an invalid value) returns - * `text` unchanged, since it is already written in lowercase. - * - * @param {string} text - The lowercase "por extenso" string to transform. - * @param {WordsCase} [wordsCase] - The case to apply. Defaults to `"lower"` (no change). - * @returns {string} `text` with the requested case applied. - * - * @example - * ```typescript - * applyWordsCase("três reais"); // "três reais" - * applyWordsCase("três reais", "sentence"); // "Três reais" - * applyWordsCase("três reais", "upper"); // "TRÊS REAIS" - * ``` - */ -export const applyWordsCase = (text: string, wordsCase?: WordsCase): string => { - if (wordsCase === "upper") return text.toLocaleUpperCase("pt-BR"); - if (wordsCase === "sentence") return text.charAt(0).toLocaleUpperCase("pt-BR") + text.slice(1); - - return text; -}; diff --git a/src/_internals/calculate-cei-check-digit/calculate-cei-check-digit.ts b/src/_internals/calculate-cei-check-digit/calculate-cei-check-digit.ts index 24950f1f..74800c63 100644 --- a/src/_internals/calculate-cei-check-digit/calculate-cei-check-digit.ts +++ b/src/_internals/calculate-cei-check-digit/calculate-cei-check-digit.ts @@ -26,9 +26,11 @@ import { generateChecksum } from "../generate-checksum/generate-checksum"; * The registry's own page at the Receita Federal, which describes the cadastro but publishes * neither the mask nor the check digit rule. * @see Official: https://dados.gov.br/dados/conjuntos-dados/cadastro-nacional-de-obras-cno - * Cadastro Nacional de Obras (CNO), dados abertos da Receita Federal: the 38432 works - * registered in Minas Gerais confirm the rule, and their check digits of 0 are what shows - * that a computed 10 maps back to 0, which neither reference implementation does. + * Cadastro Nacional de Obras (CNO), dados abertos da Receita Federal: the catalogue entry for the + * dataset this rule was cross-checked against. The Minas Gerais extract of the downloaded dataset + * confirms the rule, and the works whose check digit is 0 are what shows that a computed 10 maps + * back to 0, which neither reference implementation does; the catalogue page itself publishes only + * the dataset's description and download links (and currently flags it "Desatualizado"). * @see Based on: https://github.com/yiibr/yii2-br-validator/blob/master/src/CeiValidator.php * PHP reference implementation of the CEI check digit. * @see Based on: https://github.com/marcos-cruz/Documento/blob/master/src/Bigai.Documentos.Brasil/Cei/Cei.cs diff --git a/src/_internals/calculate-renavam-check-digit/calculate-renavam-check-digit.test.ts b/src/_internals/calculate-renavam-check-digit/calculate-renavam-check-digit.test.ts new file mode 100644 index 00000000..cd3a82b6 --- /dev/null +++ b/src/_internals/calculate-renavam-check-digit/calculate-renavam-check-digit.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "../test/runtime"; +import { calculateRenavamCheckDigit } from "./calculate-renavam-check-digit"; + +describe("calculateRenavamCheckDigit", () => { + test("should return 2 for the base of 00639884962 (klawdyo/validation-br renavam fixture)", () => { + expect(calculateRenavamCheckDigit("0063988496")).toBe(2); + }); + + test("should return 0 for the base of 12345678900, where the multiplier wraps from 9 back to 2", () => { + expect(calculateRenavamCheckDigit("1234567890")).toBe(0); + }); + + test("should return 0 when the product leaves a remainder of 10 (base of 00000000060)", () => { + expect(calculateRenavamCheckDigit("0000000006")).toBe(0); + }); + + test("should return 1 for the base of 00000000051, where only the rightmost digit weighs", () => { + expect(calculateRenavamCheckDigit("0000000005")).toBe(1); + }); + + test("should return 6 for the base of 90000000006, where only the leftmost digit weighs", () => { + expect(calculateRenavamCheckDigit("9000000000")).toBe(6); + }); + + test("should return 0 for a base of only zeros", () => { + expect(calculateRenavamCheckDigit("0000000000")).toBe(0); + }); +}); diff --git a/src/_internals/calculate-renavam-check-digit/calculate-renavam-check-digit.ts b/src/_internals/calculate-renavam-check-digit/calculate-renavam-check-digit.ts new file mode 100644 index 00000000..6b0224db --- /dev/null +++ b/src/_internals/calculate-renavam-check-digit/calculate-renavam-check-digit.ts @@ -0,0 +1,49 @@ +const FIRST_MULTIPLIER = 2; + +const LAST_MULTIPLIER = 9; + +const MODULUS = 11; + +const SUM_SCALE = 10; + +const OVERFLOW_DIGIT = 10; + +/** + * Calculates the check digit of a RENAVAM (Registro Nacional de Veículos Automotores) base, the + * eleventh digit of the registration. + * + * The ten base digits are read from right to left and multiplied by 2, 3, 4, 5, 6, 7, 8, 9 and + * then 2 again, cycling back whenever the multiplier passes 9. The weighted sum is multiplied by + * ten and the check digit is the remainder of that product by eleven, with a remainder of ten + * mapped back to 0. + * + * The Código de Trânsito Brasileiro creates the RENAVAM registry but does not define its check + * digit, so the calculation follows the two community references cited below. + * + * @param {string} base - The ten digits that precede the check digit. + * @returns {number} The check digit, 0 to 9. + * + * @example + * ```typescript + * calculateRenavamCheckDigit("0063988496"); // 2 + * calculateRenavamCheckDigit("1234567890"); // 0 + * ``` + * + * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l9503compilado.htm + * @see Based on: https://github.com/klawdyo/validation-br/blob/main/src/renavam.ts + * @see Based on: https://github.com/brazilian-utils/python/blob/main/brutils/renavam.py + */ +export const calculateRenavamCheckDigit = (base: string): number => { + let sum = 0; + let multiplier = FIRST_MULTIPLIER; + + for (let index = base.length - 1; index >= 0; index--) { + sum += Number.parseInt(base.charAt(index), 10) * multiplier; + + multiplier = multiplier >= LAST_MULTIPLIER ? FIRST_MULTIPLIER : multiplier + 1; + } + + const digit = (sum * SUM_SCALE) % MODULUS; + + return digit === OVERFLOW_DIGIT ? 0 : digit; +}; diff --git a/src/_internals/constants/area-codes.ts b/src/_internals/constants/area-codes.ts index e2e4b34b..975025a8 100644 --- a/src/_internals/constants/area-codes.ts +++ b/src/_internals/constants/area-codes.ts @@ -116,7 +116,8 @@ export const AREA_CODE_STATES: Record = { * state. * * @see Official: https://www.gov.br/anatel/pt-br/regulado/numeracao/codigos-nacionais - * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2001/383-resolucao-263 + * @see Based on: https://informacoes.anatel.gov.br/legislacao/resolucoes/2001/383-resolucao-263 + * Anexo of Resolução nº 263/2001 (revoked; still the table Anatel's Códigos Nacionais page links to). */ export const AREA_CODE_SECONDARY_STATES: Record = { 42: ["SC"], diff --git a/src/_internals/constants/cei.ts b/src/_internals/constants/cei.ts index d11fbce1..41aac0eb 100644 --- a/src/_internals/constants/cei.ts +++ b/src/_internals/constants/cei.ts @@ -10,9 +10,11 @@ * The registry's own page at the Receita Federal, which describes the cadastro but publishes * neither the mask nor the check digit rule. * @see Official: https://dados.gov.br/dados/conjuntos-dados/cadastro-nacional-de-obras-cno - * Cadastro Nacional de Obras (CNO), dados abertos da Receita Federal: every one of the 38432 - * works registered in Minas Gerais passes this check, which is what ties the CNO to the CEI - * rule and where the test vectors come from. + * Cadastro Nacional de Obras (CNO), dados abertos da Receita Federal: the catalogue entry for the + * dataset this rule was cross-checked against and where the test vectors come from. The check was + * run over the Minas Gerais extract of the downloaded dataset, which every registered work passed; + * the catalogue page itself publishes only the dataset's description and download links (and + * currently flags it "Desatualizado"), not that result. * @see Based on: https://github.com/yiibr/yii2-br-validator/blob/master/src/CeiValidator.php * PHP reference implementation of the CEI check digit. * @see Based on: https://github.com/marcos-cruz/Documento/blob/master/src/Bigai.Documentos.Brasil/Cei/Cei.cs @@ -23,6 +25,12 @@ export const CEI_BASE_LENGTH = 11; export const CEI_WEIGHTS = [7, 4, 1, 8, 5, 2, 1, 6, 3, 7, 4]; -export const CEI_FORMAT_REGEX = /^\d{2}[\s.\-/]?\d{3}[\s.\-/]?\d{5}[\s.\-/]?\d{2}$/; +/** + * Shape a CEI/CNO number has to be written in: the 12 digits, optionally split into the printed + * groups of 2, 3, 5 and 2 by whitespace or the usual mask characters. A run of separators is + * tolerated between two groups, not just a single one, which is what the CPF, CNPJ, CAEPF and + * certidão regexes of this library do. + */ +export const CEI_FORMAT_REGEX = /^\d{2}[\s.\-/]*\d{3}[\s.\-/]*\d{5}[\s.\-/]*\d{2}$/; export const CEI_PATTERN = "00.000.00000/00"; diff --git a/src/_internals/constants/certidao.ts b/src/_internals/constants/certidao.ts index f821a69e..544d6406 100644 --- a/src/_internals/constants/certidao.ts +++ b/src/_internals/constants/certidao.ts @@ -3,14 +3,25 @@ * 6 (CNS da serventia) + 2 (acervo) + 2 (serviço) + 4 (ano) + 1 (tipo do livro) + 5 (livro) + * 3 (folha) + 7 (termo) + 2 (dígitos verificadores). * - * @see Official: https://atos.cnj.jus.br/atos/detalhar/5243 Código Nacional de Normas da - * Corregedoria Nacional de Justiça - Foro Extrajudicial (Provimento CNJ nº 149/2023), art. 473 - * in the wording of the Provimento CN nº 182, de 17/09/2024: the in-force layout of the 32 - * digit matrícula. - * @see Official: https://atos.cnj.jus.br/atos/detalhar/1311 Provimento CNJ nº 2, de 27/04/2009, - * which instituted the modelos únicos de certidão and the matrícula (revoked; historical). - * @see Based on: http://ghiorzi.org/DVnew.htm Worked example of the two check digits - * (sums 288 and 309). + * @see Official: https://atos.cnj.jus.br/atos/detalhar/5243 + * Código Nacional de Normas da Corregedoria Nacional de Justiça - Foro Extrajudicial (Provimento + * CNJ nº 149/2023), art. 473 as currently published: the in-force layout of the 32 digit + * matrícula. Inciso II and §§ 1º and 3º to 5º carry the redação of the Provimento CN nº 237, de + * 13/07/2026; the rest of the article, § 2º included, and the digit layout this library depends + * on, come from the Provimento CN nº 182, de 17/09/2024. + * @see Official: https://atos.cnj.jus.br/atos/detalhar/1311 + * Provimento CNJ nº 2, de 27/04/2009, art. 1º and 2º, which instituted the modelos únicos de + * certidão and ordered that "as certidões passarão a consignar matrícula que identifica o código + * nacional da serventia, o código do acervo, o tipo do serviço prestado, o tipo do livro, o número + * do livro, o número da folha, o número do termo e o digito verificador" (revoked; historical). + * @see Official: https://atos.cnj.jus.br/atos/detalhar/1310 + * Provimento CNJ nº 3, de 17/11/2009, art. 7º, which is where that matrícula first got its digit + * structure: "a matrícula, de inserção obrigatória nas certidões (primeira e demais vias) emitidas + * pelos Cartórios de Registro Civil das Pessoas Naturais a partir de 1º de janeiro de 2010, é + * formada pelos seguintes elementos", incisos I to IX fixing the same 6 + 2 + 2 + 4 + 1 + 5 + 3 + + * 7 + 2 positions art. 473 carries today (revoked; historical). + * @see Based on: http://ghiorzi.org/DVnew.htm + * Worked example of the two check digits (sums 288 and 309). * @see Based on: https://github.com/klawdyo/validation-br/blob/feat-certidao/src/certidao.ts * Reference implementation, and the source of the matrículas used as test vectors. * @see Based on: https://github.com/geekcom/validator-docs/blob/master/src/validator-docs/Rules/Certidao.php diff --git a/src/_internals/constants/cfop.ts b/src/_internals/constants/cfop.ts index 7184c7fe..ee47d5fb 100644 --- a/src/_internals/constants/cfop.ts +++ b/src/_internals/constants/cfop.ts @@ -16,6 +16,8 @@ * Anexo II of Convênio SINIEF s/nº 1970, the CFOP table in force. * @see Official: https://www.confaz.fazenda.gov.br/legislacao/ajustes/sinief/cvsn_70 * Convênio SINIEF s/nº 1970, the consolidated text the annex belongs to. + * @see Official: https://www.confaz.fazenda.gov.br/legislacao/ajustes/2025/AJ039_25 + * Ajuste SINIEF 39/25, the last amendment the annex carries (CFOP 7.667, from 01.02.26). * @see Official: https://www.confaz.fazenda.gov.br/legislacao/ajustes/2001/AJ_007_01 * Ajuste SINIEF 07/01, the historical text that gave the CFOP its 4 digit form. */ diff --git a/src/_internals/constants/cnae.ts b/src/_internals/constants/cnae.ts index 3ae058dd..377b36ac 100644 --- a/src/_internals/constants/cnae.ts +++ b/src/_internals/constants/cnae.ts @@ -1,11 +1,21 @@ /** - * CNAE 2.3 (Classificação Nacional de Atividades Econômicas) subclasses, indexed by the - * raw 7 digit code, mapping to the official subclass description. + * CNAE-Subclasses 2.3 (Classificação Nacional de Atividades Econômicas) subclasses, indexed by + * the raw 7 digit code, mapping to the official subclass description. + * + * 2.3 is the current subclass revision of CNAE 2.0: CONCLA's own CNAE browser lists it as + * "CNAE-Subclasses 2.3" under "CNAE 2.0 (Res 02/2010)" and tells anyone opening an older table + * that the "Versões atuais da CNAE" are "CNAE 2.0 (Res 02/2010)" and "CNAE-Subclasses 2.3". The + * classification's landing page still describes the parent CNAE 2.0 itself ("Base Legal: + * Resolução Concla 01/2006", 1301 subclasses); the 1332 subclasses below are the ones the IBGE + * data service publishes for the 2.3 revision. * * Generated by `node ./scripts/cnae.ts`. Do not edit by hand. * * @see Official: https://servicodados.ibge.gov.br/api/v2/cnae/subclasses + * @see Official: https://concla.ibge.gov.br/busca-online-cnae.html + * CONCLA's CNAE search and structure browser, which publishes CNAE-Subclasses 2.3. * @see Official: https://concla.ibge.gov.br/classificacoes/por-tema/atividades-economicas/classificacao-nacional-de-atividades-economicas + * CNAE 2.0, the parent classification the 2.3 subclass revision belongs to. */ export const CNAE_SUBCLASSES: Record = { "1011201": "FRIGORÍFICO - ABATE DE BOVINOS", diff --git a/src/_internals/constants/cns.ts b/src/_internals/constants/cns.ts index 6d9b9e79..8756653e 100644 --- a/src/_internals/constants/cns.ts +++ b/src/_internals/constants/cns.ts @@ -13,9 +13,11 @@ /** * Shape a CNS number has to be written in: the 15 digits, optionally split into the printed - * groups of 3, 4, 4 and 4 by whitespace or the usual mask characters. + * groups of 3, 4, 4 and 4 by whitespace or the usual mask characters. A run of separators is + * tolerated between two groups, not just a single one, which is what the CPF, CNPJ, CAEPF and + * certidão regexes of this library do. */ -export const CNS_FORMAT_REGEX = /^\d{3}[\s.\-/]?\d{4}[\s.\-/]?\d{4}[\s.\-/]?\d{4}$/; +export const CNS_FORMAT_REGEX = /^\d{3}[\s.\-/]*\d{4}[\s.\-/]*\d{4}[\s.\-/]*\d{4}$/; /** Digits of the PIS/PASEP/NIS derived base embedded in a definitive CNS (starts with 1 or 2). */ export const CNS_DEFINITIVE_BASE_LENGTH = 11; diff --git a/src/_internals/constants/iban.ts b/src/_internals/constants/iban.ts index b1b83350..be4ea4b3 100644 --- a/src/_internals/constants/iban.ts +++ b/src/_internals/constants/iban.ts @@ -8,17 +8,26 @@ * usual values. Circular BCB nº 3.625/2013 art. 2º § 1º numbers the owner indicator `1` for * the first or only holder, `2` for the second and so on up to the ninth, then `A` to `Z` from * the tenth, so `0` is not a valid owner indicator. + * The two sources disagree on the account type: art. 2º VI of the same Circular calls it "um + * caractere alfanumérico", while the ISO 13616 registry pattern `1!a` makes it a letter, and the + * registry is the form followed here, so a digit in that position is deliberately rejected. * Only Brazilian IBANs follow this layout; every other ISO 13616 country has its own. - * @see Official: https://www.bcb.gov.br/pre/normativos/circ/2013/pdf/circ_3625_v1_O.pdf Circular BCB nº 3.625/2013 - * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/Documents/sistema_pagamentos_brasileiro/IBAN-Guidelines_%20port.pdf Diretrizes de Implementação do IBAN no Brasil + * @see Official: https://www.bcb.gov.br/pre/normativos/circ/2013/pdf/circ_3625_v1_O.pdf + * Circular BCB nº 3.625/2013 + * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/Documents/sistema_pagamentos_brasileiro/IBAN-Guidelines_%20port.pdf + * Diretrizes de Implementação do IBAN no Brasil */ export const BR_IBAN_LENGTH = 29; export const BR_IBAN_REGEX = /^BR\d{2}\d{8}\d{5}\d{10}[A-Z][A-Z1-9]$/; /** - * Shape an IBAN has to be written in: the ISO 13616 print format, letters and digits in - * groups separated by a single space. Any other character (a hyphen, a dot, a slash) makes - * the value something other than an IBAN, so it is rejected instead of stripped. + * Shape an IBAN has to be written in: letters and digits, optionally split into the ISO 13616 + * print groups of 4 (the last one shorter, 1 to 3 characters, when the length is not a multiple + * of 4) by whitespace, `.`, `-` or `/`, the same interchangeable mask characters `isValidCpf` + * and `isValidCnpj` accept. A separator inside a group, a group of any other size, a run of + * separators between two groups (ISO 13616 prints a single one) or any character outside letters + * and digits makes the value something other than an IBAN, so it is rejected instead of stripped. */ -export const IBAN_FORMAT_REGEX = /^[A-Za-z0-9]+(?: [A-Za-z0-9]+)*$/; +export const IBAN_FORMAT_REGEX = + /^[A-Za-z0-9]{4}(?:[\s.\-/]?[A-Za-z0-9]{4})*(?:[\s.\-/]?[A-Za-z0-9]{1,3})?$/; diff --git a/src/_internals/constants/legal-nature-categories.ts b/src/_internals/constants/legal-nature-categories.ts new file mode 100644 index 00000000..1b483ffd --- /dev/null +++ b/src/_internals/constants/legal-nature-categories.ts @@ -0,0 +1,31 @@ +/** The CONCLA category (natureza jurídica group) a legal nature code belongs to. */ +export type LegalNatureCategory = { + /** The category code, the first digit shared by every legal nature code in the group. */ + code: "1" | "2" | "3" | "4" | "5"; + /** The official category title in Portuguese, per IBGE/CONCLA. */ + description: string; +}; + +/** + * The five categories of the Tabela de Natureza Jurídica 2021 (IBGE/CONCLA), indexed by the + * first digit of the four digit code: the table groups its codes under these headings, so + * "2062" (Sociedade Empresária Limitada) belongs to "2" (Entidades Empresariais). The legacy + * codes kept in `LEGAL_NATURE` for 2.3.0 compatibility follow the same rule. + * + * The CONCLA table page sits behind a bot filter and answers HTTP 403 to every non-browser + * client, so it has to be opened in a browser; the detailed structure PDF next to it is served + * normally and prints the same five headings. + * + * @see Official: https://concla.ibge.gov.br/estrutura/natjur-estrutura/natureza-juridica-2021 + * @see Official: https://concla.ibge.gov.br/images/concla/documentacao/CONCLA-TNJ2021-EstruturaDetalhada.pdf + */ +export const LEGAL_NATURE_CATEGORIES: Record = { + "1": { code: "1", description: "Administração Pública" }, + "2": { code: "2", description: "Entidades Empresariais" }, + "3": { code: "3", description: "Entidades sem Fins Lucrativos" }, + "4": { code: "4", description: "Pessoas Físicas" }, + "5": { + code: "5", + description: "Organizações Internacionais e Outras Instituições Extraterritoriais", + }, +}; diff --git a/src/_internals/constants/nfe-key.ts b/src/_internals/constants/nfe-key.ts index d9b06eb8..f0bc29bc 100644 --- a/src/_internals/constants/nfe-key.ts +++ b/src/_internals/constants/nfe-key.ts @@ -1,2 +1,9 @@ /** Digits of a DF-e (NF-e, NFC-e, CT-e or MDF-e) access key (chave de acesso). */ export const NFE_KEY_LENGTH = 44; + +/** + * The prefixes the `Id` attribute of a DF-e XML puts in front of the 44 digits, one per + * document: `NFe`, `CTe`, `MDFe`, `BPe`, `NF3e` and `NFCom`. Stripped before the digits are + * read, since `NF3e` carries a digit of its own. Shared by `getNfeKeyInfo` and `parseNfeKey`. + */ +export const XML_ID_PREFIX_REGEX = /^(?:nfe|cte|mdfe|bpe|nf3e|nfcom)/i; diff --git a/src/_internals/constants/number-words.ts b/src/_internals/constants/number-words.ts index 4d6b5b15..d68e7d43 100644 --- a/src/_internals/constants/number-words.ts +++ b/src/_internals/constants/number-words.ts @@ -2,8 +2,16 @@ * Portuguese (pt-BR) number-to-words tables, shared by `numberToWords` and by every public * "por extenso" formatter (`convertNumberToWords`, `convertCurrencyToWords`, `convertDateToWords`). * - * @see Based on: https://github.com/brazilian-utils/python/blob/main/brutils/currency.py - * "catorze" (not "quatorze") is used for 14, matching num2words pt_BR and brutils. + * @see Official: https://www.planalto.gov.br/ccivil_03/_ato2023-2026/2024/lei/L14822.htm + * Lei nº 14.822/2024 (Lei Orçamentária Anual de 2024), which spells 14 "quatorze" in the caput of + * art. 2º and in the caput of art. 3º, both writing the same amount out as "cinco trilhões + * quatrocentos e quatorze bilhões novecentos e dezenove milhões quatrocentos e noventa e dois mil + * novecentos e oitenta e seis reais" (the only two occurrences of the word in the law; art. 1º + * writes an amount with no 14 in it), the form the official Brazilian texts use; the Vocabulário + * Ortográfico admits both "catorze" and "quatorze", and num2words' Portuguese table (below) picks + * "quatorze". + * @see Based on: https://github.com/savoirfairelinux/num2words/blob/master/num2words/lang_PT.py + * num2words' Portuguese table, the source of every other word of this file. */ export const ZERO_WORD = "zero"; @@ -23,7 +31,7 @@ export const UNITS: readonly string[] = [ "onze", "doze", "treze", - "catorze", + "quatorze", "quinze", "dezesseis", "dezessete", diff --git a/src/_internals/constants/processo-juridico.ts b/src/_internals/constants/processo-juridico.ts index e96ff847..57e5e72d 100644 --- a/src/_internals/constants/processo-juridico.ts +++ b/src/_internals/constants/processo-juridico.ts @@ -1,2 +1,67 @@ +/** + * Número Único de Processo (`NNNNNNN-DD.AAAA.J.TR.OOOO`) of Resolução CNJ nº 65/2008: the length + * of the digits only value and the closed list of tribunal codes (TR) each órgão code (J) accepts. + * + * `J` comes from art. 1º, § 4º, which names one segment per digit: Supremo Tribunal Federal `1`, + * Conselho Nacional de Justiça `2`, Superior Tribunal de Justiça `3`, Justiça Federal `4`, + * Justiça do Trabalho `5`, Justiça Eleitoral `6`, Justiça Militar da União `7`, Justiça dos + * Estados e do Distrito Federal e Territórios `8` and Justiça Militar Estadual `9`. + * + * `TR` comes from art. 1º, § 5º, whose incisos close the list segment by segment: `00` for the + * processes originating in the STF, the CNJ, the STJ, the TST, the TSE and the STM (inciso I); + * `90` for those originating in the Conselho da Justiça Federal and in the Conselho Superior da + * Justiça do Trabalho (inciso II); `01` to `06` for the Tribunais Regionais Federais (inciso III, + * in the wording Resolução CNJ nº 477/2022 gave it to seat the TRF da 6ª Região created by Lei nº + * 14.226/2021); `01` to `24` for the Tribunais Regionais do Trabalho (inciso IV); `01` to `27` + * for the Tribunais Regionais Eleitorais (inciso V); `01` to `12` for the Circunscrições + * Judiciárias Militares (inciso VI); `01` to `27` for the Tribunais de Justiça (inciso VII); and + * `13`, `21` and `26` for the Tribunais de Justiça Militar of Minas Gerais, Rio Grande do Sul and + * São Paulo (inciso VIII). + * + * The unidade de origem (`OOOO`) is left out on purpose: art. 1º, § 6º hands its codification to + * each tribunal, which only has to publish its own list on its website, so there is no central + * roll to check a code against. + * + * @see Official: https://atos.cnj.jus.br/atos/detalhar/119 + * Resolução CNJ nº 65, de 16 de dezembro de 2008, whose art. 1º, § 4º and § 5º carry the two + * lists above and whose Anexos I to VII print one example number per tribunal. + * @see Official: https://atos.cnj.jus.br/atos/detalhar/4781 + * Resolução CNJ nº 477, de 10 de outubro de 2022, art. 1º: "nos processos da Justiça Federal, os + * Tribunais Regionais Federais devem ser identificados no campo (TR) pelos números de 01 a 06, + * observadas as respectivas regiões". Its Anexo II prints `0000100-15.2008.406.0000` for the TRF + * da 6ª Região. + * @see Official: https://www.planalto.gov.br/ccivil_03/_ato2019-2022/2021/lei/l14226.htm + * Lei nº 14.226, de 20 de outubro de 2021, art. 1º: "É criado o Tribunal Regional Federal da 6ª + * Região, com sede em Belo Horizonte e jurisdição no Estado de Minas Gerais", the court Resolução + * CNJ nº 477/2022 added to the TR range of the Justiça Federal. + */ + /** Digits of a processo jurídico number (`NNNNNNNDDAAAAJTROOOO`, Resolução CNJ nº 65/2008). */ export const PROCESSO_JURIDICO_LENGTH = 20; + +/** + * @param {number} first Lowest code of the range. + * @param {number} last Highest code of the range. + * @returns {number[]} Every code from `first` to `last`, both included. + */ +const range = (first: number, last: number): number[] => + Array.from({ length: last - first + 1 }, (_, index) => first + index); + +/** Superior court of a segment, which files its own processes under a zeroed `TR` (§ 5º, I). */ +const SUPERIOR_COURT = 0; + +/** Conselho da Justiça Federal and Conselho Superior da Justiça do Trabalho (§ 5º, II). */ +const COUNCIL = 90; + +/** Tribunal codes (`TR`) Resolução CNJ nº 65/2008 allows under each órgão code (`J`). */ +export const PROCESSO_JURIDICO_TRIBUNALS: ReadonlyMap = new Map([ + [1, [SUPERIOR_COURT]], + [2, [SUPERIOR_COURT]], + [3, [SUPERIOR_COURT]], + [4, [...range(1, 6), COUNCIL]], + [5, [SUPERIOR_COURT, ...range(1, 24), COUNCIL]], + [6, [SUPERIOR_COURT, ...range(1, 27)]], + [7, [SUPERIOR_COURT, ...range(1, 12)]], + [8, range(1, 27)], + [9, [13, 21, 26]], +]); diff --git a/src/_internals/constants/service-phone.ts b/src/_internals/constants/service-phone.ts index bf2a7eeb..f41afcf0 100644 --- a/src/_internals/constants/service-phone.ts +++ b/src/_internals/constants/service-phone.ts @@ -16,20 +16,37 @@ * library does not enforce, since it validates structure only. * - **Código de Acesso a Serviços de Utilidade Pública (SUP)**, art. 13-14: 3 digits, with the * whole `1N₂N₁` range destined to SUP and every other 3-digit series held in reserva técnica. - * Individual codes are designated one by one by Anatel Ato, so the codes below are the - * consolidated list Anatel publishes, not the full `100`-`199` range (see `isValidServicePhone` - * for the `112`/`911` mobile-alias note). + * Individual codes are designated one by one by Anatel Ato, the consolidated table being the + * Anexo of Ato nº 43.151/2004, so the codes below are the ones Anatel has designated rather + * than the full `100`-`199` range. `112` and `911` are *not* among them: `911` is not even + * inside the `1N₂N₁` address space art. 13 destines to SUP, and neither code appears in the + * Anexo of Ato nº 43.151/2004 or in Ato nº 12.712/2024. Their routing on Brazilian handsets is + * a GSM convention of the handset, not an Anatel designation, so both are rejected here. * - **The abbreviated `300X`/`400X` numbers** (`3003-1234`, `4004-1234`) are *not* a regulatory * category at all. They are ordinary 8-digit geographic STFC user numbers (art. 11 assigns * `2`-`6` as the first digit of a fixed-line number) whose 4-digit prefix a carrier licenses * in many DDDs at once and points at a single customer, marketed as "Número Único". Anatel - * neither names them nor publishes an allocated list, so the roots below are the conventional - * ones rather than an official allocation. + * withdrew the 4-digit special service codes instead of allocating them: Resolução nº 86/1998 + * art. 43 I, in its last wording (Resolução nº 241, de 30 de novembro de 2000, which superseded + * the Resolução nº 229/2000 one), ordered the prestadoras de STFC to release "até 30 de julho de + * 2001, os códigos de serviços especiais com 4 caracteres que estejam em uso", and Ato nº + * 43.151/2004 art. 2º II repeated the order with a 180-day deadline. So the roots below are the + * conventional ones the market settled on, not an official allocation. * * Display formatting is convention too: no Anatel document specifies one. `0800 123 4567` (4-3-4) * is the grouping used on gov.br, and `4004-1234` the one carriers print. * * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749 + * Resolução Anatel nº 749/2022, the Regulamento de Numeração in force. + * @see Official: https://informacoes.anatel.gov.br/legislacao/atos-de-numeracao/2004/1648-ato-43151 + * Ato Anatel nº 43.151/2004, whose Anexo is the consolidated SUP designation table. + * @see Official: https://informacoes.anatel.gov.br/legislacao/atos-de-numeracao/2140-ato-12712 + * Ato Anatel nº 12.712, de 04/09/2024, art. 1º: the Procedimento para a Atribuição e Designação + * de Recursos de Numeração (Anexo I), in force since 03/12/2024, whose items 10.6 and 12.1 carry + * the `500` donation-amount rule and the `900` reserva técnica. + * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/1998/336-resolucao-86 + * Resolução Anatel nº 86/1998 (revoked), art. 43 I: the release of the 4-character codes, in the + * redação dada pela Resolução nº 241, de 30 de novembro de 2000, the last one the page carries. */ export const SERVICE_PHONE_NON_GEOGRAPHIC_PREFIXES = [ @@ -60,7 +77,6 @@ export const SERVICE_PHONE_UTILITY_CODES = [ "105", "106", "111", - "112", "115", "116", "117", @@ -78,6 +94,7 @@ export const SERVICE_PHONE_UTILITY_CODES = [ "135", "136", "138", + "141", "142", "145", "146", @@ -117,5 +134,4 @@ export const SERVICE_PHONE_UTILITY_CODES = [ "197", "198", "199", - "911", ] as const; diff --git a/src/_internals/is-supported-holiday-year/is-supported-holiday-year.test.ts b/src/_internals/is-supported-holiday-year/is-supported-holiday-year.test.ts new file mode 100644 index 00000000..35ae5b4b --- /dev/null +++ b/src/_internals/is-supported-holiday-year/is-supported-holiday-year.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "../test/runtime"; +import { isSupportedHolidayYear } from "./is-supported-holiday-year"; + +describe("isSupportedHolidayYear", () => { + test("should accept a year inside the range the holiday tables cover", () => { + expect(isSupportedHolidayYear(2024)).toBe(true); + }); + + test("should accept both bounds, 1900 and 2099, inclusively", () => { + expect(isSupportedHolidayYear(1900)).toBe(true); + expect(isSupportedHolidayYear(2099)).toBe(true); + }); + + test("should reject the year right below and right above the range", () => { + expect(isSupportedHolidayYear(1899)).toBe(false); + expect(isSupportedHolidayYear(2100)).toBe(false); + }); +}); diff --git a/src/_internals/is-supported-holiday-year/is-supported-holiday-year.ts b/src/_internals/is-supported-holiday-year/is-supported-holiday-year.ts new file mode 100644 index 00000000..d157d2bf --- /dev/null +++ b/src/_internals/is-supported-holiday-year/is-supported-holiday-year.ts @@ -0,0 +1,22 @@ +import { HOLIDAYS_MAX_YEAR, HOLIDAYS_MIN_YEAR } from "../constants/holidays"; + +/** + * Checks whether a year is inside the range the holiday tables cover. + * + * `getHolidays` only computes 1900 through 2099, so every date utility built on top of it + * (`isBusinessDay`, `addBusinessDays`, `subBusinessDays`, `differenceInBusinessDays`) refuses a + * year outside that range instead of silently answering as if there were no holidays in it. + * + * @param {number} year - The full year to check, as `Date#getFullYear` reports it. + * @returns {boolean} True when the bundled holiday tables cover the year. + * + * @example + * ```typescript + * isSupportedHolidayYear(2024); // true + * isSupportedHolidayYear(1900); // true (inclusive lower bound) + * isSupportedHolidayYear(2099); // true (inclusive upper bound) + * isSupportedHolidayYear(2100); // false + * ``` + */ +export const isSupportedHolidayYear = (year: number): boolean => + year >= HOLIDAYS_MIN_YEAR && year <= HOLIDAYS_MAX_YEAR; diff --git a/src/_internals/is-valid-cei-cno-number/is-valid-cei-cno-number.ts b/src/_internals/is-valid-cei-cno-number/is-valid-cei-cno-number.ts index 54071830..e08c4458 100644 --- a/src/_internals/is-valid-cei-cno-number/is-valid-cei-cno-number.ts +++ b/src/_internals/is-valid-cei-cno-number/is-valid-cei-cno-number.ts @@ -2,6 +2,7 @@ import { calculateCeiCheckDigit } from "../calculate-cei-check-digit/calculate-c import { CEI_BASE_LENGTH, CEI_FORMAT_REGEX } from "../constants/cei"; import { isRepeatedDigits } from "../is-repeated-digits/is-repeated-digits"; import { sanitizeToDigits } from "../sanitize-to-digits/sanitize-to-digits"; +import { toStringSafe } from "../to-string-safe/to-string-safe"; /** * Validates a number that follows the CEI (Cadastro Específico do INSS) numbering, which the @@ -12,6 +13,11 @@ import { sanitizeToDigits } from "../sanitize-to-digits/sanitize-to-digits"; * that sum to its units part and takes the complement of the units digit of the result to 10, * mapping 10 back to 0. * + * The value has to be written as the 12 digits, optionally split into the printed groups of 2, + * 3, 5 and 2 by whitespace or the usual mask characters, a run of them between two groups + * included; anything else, a letter among the digits included, is rejected instead of being + * read past. + * * The Receita Federal does not publish the check digit rule of the CEI/CNO numbering, so the * calculation follows the reference implementations cited below, cross-checked against the CNO * open data of the Receita Federal. @@ -32,20 +38,20 @@ import { sanitizeToDigits } from "../sanitize-to-digits/sanitize-to-digits"; * The registry's own page at the Receita Federal, which describes the cadastro but publishes * neither the mask nor the check digit rule. * @see Official: https://dados.gov.br/dados/conjuntos-dados/cadastro-nacional-de-obras-cno - * Cadastro Nacional de Obras (CNO), dados abertos da Receita Federal: every one of the 38432 - * works registered in Minas Gerais passes this check, which is what ties the CNO to the CEI - * rule and where the test vectors come from. + * Cadastro Nacional de Obras (CNO), dados abertos da Receita Federal: the catalogue entry for the + * dataset this rule was cross-checked against and where the test vectors come from. The check was + * run over the Minas Gerais extract of the downloaded dataset, which every registered work passed; + * the catalogue page itself publishes only the dataset's description and download links (and + * currently flags it "Desatualizado"), not that result. * @see Based on: https://github.com/yiibr/yii2-br-validator/blob/master/src/CeiValidator.php * PHP reference implementation of the CEI check digit. * @see Based on: https://github.com/marcos-cruz/Documento/blob/master/src/Bigai.Documentos.Brasil/Cei/Cei.cs * Second, independent reference implementation agreeing with the first. */ export const isValidCeiCnoNumber = (value: string | number): boolean => { - if (typeof value !== "string" && typeof value !== "number") return false; - const digits = sanitizeToDigits(value); - if (!CEI_FORMAT_REGEX.test(String(value).trim())) return false; + if (!CEI_FORMAT_REGEX.test(toStringSafe(value).trim())) return false; if (isRepeatedDigits(digits)) return false; diff --git a/src/_internals/normalize-municipality-name/normalize-municipality-name.test.ts b/src/_internals/normalize-municipality-name/normalize-municipality-name.test.ts new file mode 100644 index 00000000..7906066f --- /dev/null +++ b/src/_internals/normalize-municipality-name/normalize-municipality-name.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "../test/runtime"; +import { normalizeMunicipalityName } from "./normalize-municipality-name"; + +describe("normalizeMunicipalityName", () => { + it("should drop the accents of a name", () => { + expect(normalizeMunicipalityName("São Paulo")).toBe("SAO PAULO"); + expect(normalizeMunicipalityName("Ceará-Mirim")).toBe("CEARA-MIRIM"); + }); + + it("should fold the casing to upper case", () => { + expect(normalizeMunicipalityName("sao paulo")).toBe("SAO PAULO"); + }); + + it("should fold the casing in the direction that expands ß to SS", () => { + expect(normalizeMunicipalityName("Paßos")).toBe(normalizeMunicipalityName("Passos")); + }); + + it("should collapse every run of internal whitespace into a single space", () => { + expect(normalizeMunicipalityName("São Paulo")).toBe("SAO PAULO"); + expect(normalizeMunicipalityName("São\t\nPaulo")).toBe("SAO PAULO"); + }); + + it("should keep a name written without the space a separate name", () => { + expect(normalizeMunicipalityName("SaoPaulo")).toBe("SAOPAULO"); + }); + + it("should trim the surrounding whitespace", () => { + expect(normalizeMunicipalityName(" São Paulo ")).toBe("SAO PAULO"); + }); + + it("should return an empty string for an empty string", () => { + expect(normalizeMunicipalityName("")).toBe(""); + }); + + it("should return an empty string for a value that is not a string", () => { + // @ts-expect-error: intentionally invalid input + expect(normalizeMunicipalityName(null)).toBe(""); + // @ts-expect-error: intentionally invalid input + expect(normalizeMunicipalityName(3_550_308)).toBe(""); + }); +}); diff --git a/src/_internals/normalize-municipality-name/normalize-municipality-name.ts b/src/_internals/normalize-municipality-name/normalize-municipality-name.ts new file mode 100644 index 00000000..b46b101f --- /dev/null +++ b/src/_internals/normalize-municipality-name/normalize-municipality-name.ts @@ -0,0 +1,29 @@ +import { removeAccents } from "../../remove-accents/remove-accents"; + +const WHITESPACE_RUN_REGEX = /\s+/g; + +/** + * Normalizes a municipality name so that two spellings of the same municipality compare equal. + * + * Accents are dropped, every run of whitespace collapses into a single space, the surrounding + * whitespace is trimmed, and the casing is folded to upper case, the direction Unicode expands + * `"ß"` to `"SS"` in, so `"Paßos"` normalizes to what `"Passos"` normalizes to. Only the runs + * of whitespace that are there collapse, so a name written without a space the dataset carries + * stays a different name. + * + * `removeAccents` already folds a value that is not a string down to `""`, which no real + * municipality name normalizes to, so a caller may hand this helper an unvalidated value and + * simply compare the result. + * + * @param {string} value - The municipality name to normalize. + * @returns {string} The normalized name, or `""` when `value` is not a non-empty string. + * + * @example + * ```typescript + * normalizeMunicipalityName("São Paulo"); // "SAO PAULO" + * normalizeMunicipalityName(" Ceará-Mirim "); // "CEARA-MIRIM" + * normalizeMunicipalityName(""); // "" + * ``` + */ +export const normalizeMunicipalityName = (value: string): string => + removeAccents(value).replaceAll(WHITESPACE_RUN_REGEX, " ").trim().toUpperCase(); diff --git a/src/_internals/number-to-words/number-to-words.test.ts b/src/_internals/number-to-words/number-to-words.test.ts index 1337bdf4..4fe34a81 100644 --- a/src/_internals/number-to-words/number-to-words.test.ts +++ b/src/_internals/number-to-words/number-to-words.test.ts @@ -15,7 +15,7 @@ describe("numberToWords", () => { expect(numberToWords(11)).toBe("onze"); expect(numberToWords(12)).toBe("doze"); expect(numberToWords(13)).toBe("treze"); - expect(numberToWords(14)).toBe("catorze"); + expect(numberToWords(14)).toBe("quatorze"); expect(numberToWords(15)).toBe("quinze"); expect(numberToWords(16)).toBe("dezesseis"); expect(numberToWords(17)).toBe("dezessete"); @@ -51,8 +51,8 @@ describe("numberToWords", () => { expect(numberToWords(1100)).toBe("mil e cem"); }); - test("should separate 'mil' from a non round last group with a comma (1235 -> num2words pt_BR 'mil, duzentos e trinta e cinco')", () => { - expect(numberToWords(1235)).toBe("mil, duzentos e trinta e cinco"); + test("should separate 'mil' from a non round last group with a comma (1235 -> num2words pt_BR 'mil duzentos e trinta e cinco')", () => { + expect(numberToWords(1235)).toBe("mil duzentos e trinta e cinco"); }); test("should return 'dois mil' for 2000 (masculine default)", () => { @@ -73,26 +73,26 @@ describe("numberToWords", () => { test("should convert the maximum supported value (999 trillion, num2words pt_BR)", () => { expect(numberToWords(NUMBER_TO_WORDS_MAX_VALUE)).toBe( - "novecentos e noventa e nove trilhões, novecentos e noventa e nove bilhões, " + - "novecentos e noventa e nove milhões, novecentos e noventa e nove mil, " + + "novecentos e noventa e nove trilhões novecentos e noventa e nove bilhões " + + "novecentos e noventa e nove milhões novecentos e noventa e nove mil " + "novecentos e noventa e nove", ); }); test("should convert a value spanning billions, millions and thousands (999999999999, num2words pt_BR)", () => { expect(numberToWords(999_999_999_999)).toBe( - "novecentos e noventa e nove bilhões, novecentos e noventa e nove milhões, " + - "novecentos e noventa e nove mil, novecentos e noventa e nove", + "novecentos e noventa e nove bilhões novecentos e noventa e nove milhões " + + "novecentos e noventa e nove mil novecentos e noventa e nove", ); }); test("should skip a zero intermediate group (1000230 -> no 'zero mil')", () => { - expect(numberToWords(1_000_230)).toBe("um milhão, duzentos e trinta"); + expect(numberToWords(1_000_230)).toBe("um milhão duzentos e trinta"); }); test("should separate an intermediate group below 100 with a comma, reserving 'e' for the last group (1045678; num2words pt_BR differs here only because its post-processing rewrites ' e ' before a hundreds word)", () => { expect(numberToWords(1_045_678)).toBe( - "um milhão, quarenta e cinco mil, seiscentos e setenta e oito", + "um milhão quarenta e cinco mil seiscentos e setenta e oito", ); }); diff --git a/src/_internals/number-to-words/number-to-words.ts b/src/_internals/number-to-words/number-to-words.ts index 94935cb3..c92bb531 100644 --- a/src/_internals/number-to-words/number-to-words.ts +++ b/src/_internals/number-to-words/number-to-words.ts @@ -12,16 +12,6 @@ import { /** The grammatical gender `convertNumberToWords` agrees the number it writes out with. */ export type NumberToWordsGender = "masculine" | "feminine"; -/** - * Letter case applied to the final "por extenso" string of `convertNumberToWords`, - * `convertCurrencyToWords` and `convertDateToWords`. `"lower"` leaves the string as produced - * (every word already lowercase); `"sentence"` capitalizes only its first letter; `"upper"` - * uppercases the whole string with the "pt-BR" locale, which keeps accents intact - * ("três" -> "TRÊS", "março" -> "MARÇO"). Defaults to `"lower"`; any other value is ignored and - * `"lower"` is used instead. - */ -export type WordsCase = "lower" | "sentence" | "upper"; - export type NumberToWordsOptions = { /** Grammatical gender used to agree "um/dois" and the 100-999 group ("duzentos/duzentas", etc.) with the noun the number qualifies. Only the thousands group and the final 0-999 group are affected: the multiplier of "milhão/bilhão/trilhão" always agrees with those (masculine) nouns. Defaults to `"masculine"`. */ gender?: NumberToWordsGender; @@ -81,21 +71,24 @@ const isRoundHundred = (value: number): boolean => value % 100 === 0; /** * Converts a non-negative integer into its Brazilian Portuguese cardinal number words - * ("por extenso"), e.g. `1235` becomes `"mil, duzentos e trinta e cinco"`. + * ("por extenso"), e.g. `1235` becomes `"mil duzentos e trinta e cinco"`. * * This is the shared engine behind every "por extenso" formatter of this library * (`convertNumberToWords`, `convertCurrencyToWords`, `convertDateToWords`): it only converts, it * never validates or sanitizes its input, so callers must pass a finite, non-negative integer - * within `[0, NUMBER_TO_WORDS_MAX_VALUE]`. Grouping uses commas between groups and "e" is used - * instead of a comma right before the last group when that group is below 100 or is a round - * hundred (100, 200, ..., 900), matching how the value would be written by hand - * (e.g. `1200` -> `"mil e duzentos"`, `1235` -> `"mil, duzentos e trinta e cinco"`). The "e" - * connector is therefore reserved for the last group: an intermediate group below 100 still takes - * a comma (`1045678` -> `"um milhão, quarenta e cinco mil, seiscentos e setenta e oito"`). This is - * the one place where the output deviates from `num2words`' pt_BR locale, which writes - * `"um milhão e quarenta e cinco mil, ..."` there because its post-processing only rewrites " e " - * into "," when the next word is a hundreds word, making an intermediate group's punctuation - * depend on the group that follows it. Every published `brutils` example is reproduced exactly. + * within `[0, NUMBER_TO_WORDS_MAX_VALUE]`. Groups are joined by a space, and "e" is used right + * before the last group when that group is below 100 or is a round hundred (100, 200, ..., 900), + * the way the official texts write amounts out: `1200` -> `"mil e duzentos"`, `1001` -> `"mil e + * um"`, `1235` -> `"mil duzentos e trinta e cinco"`, `1045678` -> `"um milhão quarenta e cinco mil + * seiscentos e setenta e oito"`. This is the spelling of the Lei Orçamentária Anual ("cinco + * trilhões quinhentos e sessenta e seis bilhões duzentos e oitenta e quatro milhões oitocentos e + * dez mil trezentos e setenta e três reais", Lei 14.822/2024, art. 1º, and "novecentos e trinta e + * um mil e oitenta e um reais" for the "e" before a last group below 100, art. 2º, inciso III), of + * the salário mínimo + * decrees ("mil quinhentos e dezoito reais", Decreto 12.342/2024) and of the examples in the Manual + * de Redação da Presidência da República ("mil duzentos e cinquenta reais", "mil e quatrocentos + * reais"). It deviates from `num2words`' pt_BR locale, which separates the groups with commas + * ("mil, duzentos e trinta e cinco") and writes "e" before an intermediate group below 100. * * @param {number} value - A non-negative integer in `[0, NUMBER_TO_WORDS_MAX_VALUE]`. * @param {NumberToWordsOptions} [options] - Optional conversion options. @@ -108,12 +101,19 @@ const isRoundHundred = (value: number): boolean => value % 100 === 0; * numberToWords(21); // "vinte e um" * numberToWords(100); // "cem" * numberToWords(1100); // "mil e cem" - * numberToWords(1235); // "mil, duzentos e trinta e cinco" + * numberToWords(1235); // "mil duzentos e trinta e cinco" * numberToWords(2000000); // "dois milhões" * numberToWords(2, { gender: "feminine" }); // "duas" * numberToWords(2000, { gender: "feminine" }); // "duas mil" * ``` * + * @see Official: https://www.planalto.gov.br/ccivil_03/_ato2023-2026/2024/lei/L14822.htm + * Lei nº 14.822, de 22 de janeiro de 2024 (Lei Orçamentária Anual de 2024): amounts written out with + * the groups separated by spaces and "e" only inside a group (art. 1º), "e" before the last group + * when that group is below 100 ("novecentos e trinta e um mil e oitenta e um reais", art. 2º, + * inciso III) and "quatorze" for 14 (art. 2º and art. 3º, caput). + * @see Official: https://www.planalto.gov.br/ccivil_03/_ato2023-2026/2024/decreto/D12342.htm + * Decreto nº 12.342, de 30 de dezembro de 2024, art. 1º: "R$ 1.518,00 (mil quinhentos e dezoito reais)". * @see Based on: https://github.com/brazilian-utils/python/blob/main/brutils/currency.py */ export const numberToWords = (value: number, options?: NumberToWordsOptions): string => { @@ -146,7 +146,7 @@ export const numberToWords = (value: number, options?: NumberToWordsOptions): st const connector = // Stryker disable next-line EqualityOperator: equivalent, groupValue === 100 already satisfies isRoundHundred(groupValue) - index === lastNonZeroIndex && (groupValue < 100 || isRoundHundred(groupValue)) ? " e " : ", "; + index === lastNonZeroIndex && (groupValue < 100 || isRoundHundred(groupValue)) ? " e " : " "; result += connector + groupText; } diff --git a/src/_internals/pad-lookup-code/pad-lookup-code.test.ts b/src/_internals/pad-lookup-code/pad-lookup-code.test.ts new file mode 100644 index 00000000..2f2eda57 --- /dev/null +++ b/src/_internals/pad-lookup-code/pad-lookup-code.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "../test/runtime"; +import { padLookupCode } from "./pad-lookup-code"; + +describe("padLookupCode", () => { + test("should left pad a number with zeros up to the given width", () => { + expect(padLookupCode(10_205, 6)).toBe("010205"); + expect(padLookupCode(0, 3)).toBe("000"); + expect(padLookupCode(5, 3)).toBe("005"); + }); + + test("should left pad a string of bare digits exactly like the number it spells", () => { + expect(padLookupCode("10205", 6)).toBe("010205"); + expect(padLookupCode("0", 3)).toBe("000"); + }); + + test("should return a value already as wide as the table unchanged", () => { + expect(padLookupCode("212405", 6)).toBe("212405"); + expect(padLookupCode(212_405, 6)).toBe("212405"); + }); + + test("should never shorten a value wider than the table", () => { + expect(padLookupCode("2124055", 6)).toBe("2124055"); + }); + + test("should trim surrounding whitespace before padding", () => { + expect(padLookupCode(" 10205 ", 6)).toBe("010205"); + expect(padLookupCode(" 212405 ", 6)).toBe("212405"); + }); + + test("should hand a masked value back untouched, even when it is narrower than the table", () => { + expect(padLookupCode("6201-5/01", 7)).toBe("6201-5/01"); + expect(padLookupCode("12-3", 6)).toBe("12-3"); + expect(padLookupCode("2124 05", 6)).toBe("2124 05"); + }); + + test("should hand a value that is not digits back untouched", () => { + expect(padLookupCode("abc", 6)).toBe("abc"); + expect(padLookupCode("2124abc05", 6)).toBe("2124abc05"); + expect(padLookupCode("+212405", 6)).toBe("+212405"); + }); + + test("should never turn an empty value into a code of zeros", () => { + expect(padLookupCode("", 6)).toBe(""); + expect(padLookupCode(" ", 6)).toBe(""); + }); +}); diff --git a/src/_internals/pad-lookup-code/pad-lookup-code.ts b/src/_internals/pad-lookup-code/pad-lookup-code.ts new file mode 100644 index 00000000..0768a5f0 --- /dev/null +++ b/src/_internals/pad-lookup-code/pad-lookup-code.ts @@ -0,0 +1,34 @@ +const BARE_DIGITS_REGEX = /^\d+$/; + +/** + * Left pads a lookup code with zeros up to the fixed width of its table, so the leading zeros a + * table's codes carry never depend on how the caller wrote the value. + * + * A table whose codes all have the same width and may start with a zero (CBO with 6 digits, + * CNAE with 7, NCM with 8) is looked up by that padded form, so `10205`, `"10205"` and + * `"010205"` are all the CBO code `010205`, the same way `getBankByCode(1)` and + * `getBankByCode("1")` are both the bank `"001"`. + * + * Only a value written as bare digits is padded: a masked value (`"6201-5/01"`) already carries + * its separators and is handed back untouched, and so is anything that is not digits at all + * (`"abc"`), which the caller's own format check then turns down. Surrounding whitespace is + * trimmed either way, and an empty value is never turned into a code of zeros. + * + * @param {string|number} value - The value to normalize, a string or a number. + * @param {number} length - The fixed digit width of the table's codes. + * @returns {string} The trimmed value, left padded with zeros when it is written as bare digits. + * + * @example + * ```typescript + * padLookupCode(10205, 6); // "010205" + * padLookupCode("10205", 6); // "010205" + * padLookupCode(" 212405 ", 6); // "212405" + * padLookupCode("6201-5/01", 7); // "6201-5/01" + * padLookupCode("", 6); // "" + * ``` + */ +export const padLookupCode = (value: string | number, length: number): string => { + const code = String(value).trim(); + + return BARE_DIGITS_REGEX.test(code) ? code.padStart(length, "0") : code; +}; diff --git a/src/_internals/resolve-state-holiday-date/resolve-state-holiday-date.test.ts b/src/_internals/resolve-state-holiday-date/resolve-state-holiday-date.test.ts index 37e3e8c7..cdcc7540 100644 --- a/src/_internals/resolve-state-holiday-date/resolve-state-holiday-date.test.ts +++ b/src/_internals/resolve-state-holiday-date/resolve-state-holiday-date.test.ts @@ -29,6 +29,28 @@ describe("resolveStateHolidayDate", () => { ); }); + test("should move a fixed date landing Monday to Friday on to the following Sunday", () => { + const rule = { day: 11, month: 8, nextSundayWhenWeekday: true }; + + expect(resolveStateHolidayDate(2025, rule)).toEqual(new Date(2025, 7, 17)); + expect(resolveStateHolidayDate(2026, rule)).toEqual(new Date(2026, 7, 16)); + expect(resolveStateHolidayDate(2027, rule)).toEqual(new Date(2027, 7, 15)); + expect(resolveStateHolidayDate(2028, rule)).toEqual(new Date(2028, 7, 13)); + }); + + test("should leave a fixed date already falling on a Saturday or a Sunday where it is", () => { + const rule = { day: 25, month: 11, nextSundayWhenWeekday: true }; + + expect(resolveStateHolidayDate(2028, rule)).toEqual(new Date(2028, 10, 25)); + expect(resolveStateHolidayDate(2029, rule)).toEqual(new Date(2029, 10, 25)); + }); + + test("should move an Easter derived date landing Monday to Friday on to the following Sunday", () => { + expect( + resolveStateHolidayDate(2024, { easterOffset: 60, nextSundayWhenWeekday: true }), + ).toEqual(new Date(2024, 5, 2)); + }); + test("should throw when the rule defines neither an Easter offset nor both day and month", () => { const message = "State holiday entry must define either `easterOffset` or both `day` and `month`"; diff --git a/src/_internals/resolve-state-holiday-date/resolve-state-holiday-date.ts b/src/_internals/resolve-state-holiday-date/resolve-state-holiday-date.ts index c1398b3a..94534217 100644 --- a/src/_internals/resolve-state-holiday-date/resolve-state-holiday-date.ts +++ b/src/_internals/resolve-state-holiday-date/resolve-state-holiday-date.ts @@ -6,8 +6,17 @@ export type HolidayDateRule = { month?: number; /** Offset in days from Easter Sunday (Carnaval is -47, Corpus Christi is 60); Easter itself is 0. */ easterOffset?: number; + /** + * Whether the holiday is observed on the following Sunday when the date the two rules above + * resolve to falls on a weekday (Monday to Friday), as Santa Catarina's two state holidays do. + */ + nextSundayWhenWeekday?: boolean; }; +const SUNDAY = 0; +const SATURDAY = 6; +const DAYS_IN_WEEK = 7; + function calculateEaster(year: number): Date { const a = year % 19; const b = Math.floor(year / 100); @@ -35,9 +44,21 @@ function calculateHolidayFromEaster(year: number, offset: number): Date { return holidayDate; } +function moveToNextSundayWhenWeekday(date: Date): Date { + const weekday = date.getDay(); + + if (weekday === SUNDAY || weekday === SATURDAY) return date; + + const observed = new Date(date); + observed.setDate(date.getDate() + (DAYS_IN_WEEK - weekday)); + + return observed; +} + /** * Resolves the date of a holiday in a given year: a fixed `day`/`month` pair, or an offset in - * days from Easter Sunday, computed with the Meeus/Jones/Butcher algorithm. + * days from Easter Sunday, computed with the Meeus/Jones/Butcher algorithm. When the rule sets + * `nextSundayWhenWeekday`, a date landing on a weekday is moved on to the following Sunday. * * @param {number} year - The four digit year. * @param {HolidayDateRule} rule - The fixed date or the Easter offset of the holiday. @@ -49,23 +70,26 @@ function calculateHolidayFromEaster(year: number, offset: number): Date { * resolveStateHolidayDate(2024, { easterOffset: 0 }); // 2024-03-31 (Easter Sunday) * resolveStateHolidayDate(2024, { easterOffset: 60 }); // 2024-05-30 (Corpus Christi) * resolveStateHolidayDate(2024, { day: 9, month: 7 }); // 2024-07-09 + * resolveStateHolidayDate(2025, { day: 11, month: 8, nextSundayWhenWeekday: true }); // 2025-08-17 * ``` * * @see Based on: https://en.wikipedia.org/wiki/Date_of_Easter#Anonymous_Gregorian_algorithm */ export const resolveStateHolidayDate = ( year: number, - { day, month, easterOffset }: HolidayDateRule, + { day, month, easterOffset, nextSundayWhenWeekday }: HolidayDateRule, ): Date => { - if (easterOffset !== undefined) { - return calculateHolidayFromEaster(year, easterOffset); - } + let date: Date; - if (day !== undefined && month !== undefined) { - return new Date(year, month - 1, day); + if (easterOffset !== undefined) { + date = calculateHolidayFromEaster(year, easterOffset); + } else if (day !== undefined && month !== undefined) { + date = new Date(year, month - 1, day); + } else { + throw new Error( + "State holiday entry must define either `easterOffset` or both `day` and `month`", + ); } - throw new Error( - "State holiday entry must define either `easterOffset` or both `day` and `month`", - ); + return nextSundayWhenWeekday === true ? moveToNextSundayWhenWeekday(date) : date; }; diff --git a/src/_internals/sanitize-to-alphanumeric/sanitize-to-alphanumeric.ts b/src/_internals/sanitize-to-alphanumeric/sanitize-to-alphanumeric.ts index ccf97d62..af016eb6 100644 --- a/src/_internals/sanitize-to-alphanumeric/sanitize-to-alphanumeric.ts +++ b/src/_internals/sanitize-to-alphanumeric/sanitize-to-alphanumeric.ts @@ -1,5 +1,8 @@ +import { toStringSafe } from "../to-string-safe/to-string-safe"; + /** * Sanitizes the input value by removing all non-alphanumeric characters and uppercasing the result. + * A value with no string conversion (an object with a null prototype) reads as `""` instead of throwing. * * @param {string|number} value - The input value to be sanitized. It can be a string or a number. * @returns {string} A string containing only uppercase alphanumeric characters from the input value. @@ -12,7 +15,6 @@ * ``` */ export const sanitizeToAlphanumeric = (value: string | number): string => - value - .toString() + toStringSafe(value) .replaceAll(/[^A-Za-z0-9]/g, "") .toUpperCase(); diff --git a/src/_internals/sanitize-to-digits/sanitize-to-digits.ts b/src/_internals/sanitize-to-digits/sanitize-to-digits.ts index 4bb380c2..57f854fa 100644 --- a/src/_internals/sanitize-to-digits/sanitize-to-digits.ts +++ b/src/_internals/sanitize-to-digits/sanitize-to-digits.ts @@ -1,5 +1,8 @@ +import { toStringSafe } from "../to-string-safe/to-string-safe"; + /** - * Sanitizes the input value by removing all non-digit characters. + * Sanitizes the input value by removing all non-digit characters. A value with no string + * conversion (an object with a null prototype) reads as `""` instead of throwing. * * @param {string|number} value - The input value to be sanitized. It can be a string or a number. * @returns {string} A string containing only the digit characters from the input value. @@ -13,4 +16,4 @@ * ``` */ export const sanitizeToDigits = (value: string | number): string => - value.toString().replaceAll(/\D/g, ""); + toStringSafe(value).replaceAll(/\D/g, ""); diff --git a/src/_internals/test/arbitraries.ts b/src/_internals/test/arbitraries.ts index a606b5eb..4bb3dc57 100644 --- a/src/_internals/test/arbitraries.ts +++ b/src/_internals/test/arbitraries.ts @@ -38,7 +38,11 @@ export const anyValue: fc.Arbitrary = fc.oneof( /** ASCII alphanumeric text, at most twelve characters long. */ export const asciiAlphanumericText: fc.Arbitrary = fc.stringMatching(/^[0-9A-Za-z]{0,12}$/); -/** Booleans, `null`, numbers, strings, arrays and plain objects, including nested primitives. */ +/** + * Booleans, `null`, numbers, strings, arrays and plain objects, including nested primitives, + * plus null-prototype objects: an object built with `Object.create(null)` has no `toString`, + * so it is the shape that catches a util reaching a sanitizer behind a nullish guard alone. + */ export const anyGarbage: fc.Arbitrary = fc.oneof( fc.boolean(), fc.constant(null), @@ -46,6 +50,7 @@ export const anyGarbage: fc.Arbitrary = fc.oneof( fc.string(), fc.array(anyPrimitive), fc.object({ key: fc.constantFrom("a", "b", "c") }), + fc.object({ withNullPrototype: true }), ); /** @@ -140,6 +145,30 @@ export const businessDayDates: fc.Arbitrary = fc.date({ noInvalidDate: true, }); +/** + * `Object.prototype`'s own keys: the ones a lookup must resolve as unknown rather than reach + * through the prototype chain. + */ +export const PROTOTYPE_KEYS: string[] = Object.getOwnPropertyNames(Object.prototype); + +/** A date, or anything at all: what a business day util may be handed as its date argument. */ +export const anyBusinessDayDate: fc.Arbitrary = fc.oneof(businessDayDates, fc.anything()); + +/** A number of business days, or anything at all: what a business day util may be asked to walk. */ +export const anyBusinessDayAmount: fc.Arbitrary = fc.oneof( + fc.integer({ min: -200, max: 200 }), + fc.anything(), +); + +const anyStateCode = fc.oneof(fc.constantFrom(...PROTOTYPE_KEYS, "SP", "xx"), fc.anything()); +const anyIncludeOptional = fc.oneof(fc.boolean(), fc.anything()); + +/** Business day options, or anything at all, prototype chain keys as the state code included. */ +export const anyBusinessDayOptions: fc.Arbitrary = fc.oneof( + fc.anything(), + fc.record({ stateCode: anyStateCode, includeOptional: anyIncludeOptional }), +); + /** An amount with at most two decimals, the precision currency formatting round-trips. */ export const twoDecimalAmounts: fc.Arbitrary = fc .integer({ min: -1_000_000_000, max: 1_000_000_000 }) diff --git a/src/_internals/test/properties.ts b/src/_internals/test/properties.ts index 08e38313..e4005002 100644 --- a/src/_internals/test/properties.ts +++ b/src/_internals/test/properties.ts @@ -40,6 +40,23 @@ export const expectNeverThrowsWithOptions = ( ); }; +/** + * Asserts the util never throws for any argument list the arbitrary produces. + * @param {Function} fn The util under test. + * @param {fc.Arbitrary} argumentLists The argument lists to spread into it. + * @returns {void} Nothing. + */ +export const expectNeverThrowsWithArguments = ( + fn: (...args: never[]) => unknown, + argumentLists: fc.Arbitrary, +): void => { + fc.assert( + fc.property(argumentLists, (values) => { + expect(() => fn(...(values as never[]))).not.toThrow(); + }), + ); +}; + /** * Asserts the util always returns a value of `expectedType`, whatever the arbitrary produces. * @param {UnknownInputFunction} fn The util under test. diff --git a/src/_internals/test/runtime-deno.ts b/src/_internals/test/runtime-deno.ts index db72f70d..72bd581b 100644 --- a/src/_internals/test/runtime-deno.ts +++ b/src/_internals/test/runtime-deno.ts @@ -13,6 +13,7 @@ type MockImplementation = (...args: unknown[]) => unknown; type MockFunction = ((...args: unknown[]) => unknown) & { mock: { calls: unknown[][] }; mockClear: () => void; + mockReset: () => void; mockRejectedValue: (value: unknown) => MockFunction; mockRejectedValueOnce: (value: unknown) => MockFunction; mockResolvedValue: (value: unknown) => MockFunction; @@ -35,8 +36,20 @@ function hasLength(value: unknown): value is { length: number } { return isRecord(value) && typeof value["length"] === "number"; } +class AssertionMismatch extends Error { + public constructor(message: string) { + super(message); + + this.name = "AssertionMismatch"; + } +} + function createAssertionError(message: string): Error { - return new Error(message); + return new AssertionMismatch(message); +} + +function createUsageError(message: string): Error { + return new TypeError(message); } function describeValue(value: unknown): string { @@ -122,8 +135,12 @@ function createMock(implementation?: MockImplementation): MockFunction { const mockFn: MockFunction = Object.assign(baseFn, { mock: { calls }, mockClear: (): void => { + calls.length = 0; + }, + mockReset: (): void => { queue.length = 0; calls.length = 0; + currentImplementation = implementation; }, mockResolvedValueOnce: (value: unknown): MockFunction => { queue.push(() => Promise.resolve(value)); @@ -306,7 +323,7 @@ const createCollectionMatchers = (actual: unknown): Matchers => ({ }, toMatch(expected: RegExp | string): void { if (typeof actual !== "string") { - throw createAssertionError("Expected value to be a string"); + throw createUsageError("Expected value to be a string"); } if (expected instanceof RegExp) { @@ -323,7 +340,7 @@ const createCollectionMatchers = (actual: unknown): Matchers => ({ }, toContainEqual(expected: unknown): void { if (!Array.isArray(actual)) { - throw createAssertionError("Expected value to be an array"); + throw createUsageError("Expected value to be an array"); } if (!actual.some((value) => deepEqual(value, expected))) { @@ -337,7 +354,7 @@ const createCollectionMatchers = (actual: unknown): Matchers => ({ }, toHaveLength(expected: number): void { if (!hasLength(actual)) { - throw createAssertionError("Expected value to have a length"); + throw createUsageError("Expected value to have a length"); } if (actual.length !== expected) { @@ -354,7 +371,7 @@ const createCollectionMatchers = (actual: unknown): Matchers => ({ const createBehaviorMatchers = (actual: unknown): Matchers => ({ toThrow(expected?: ThrowExpectation): void { if (!isCallable(actual)) { - throw createAssertionError("Expected value to be a function"); + throw createUsageError("Expected value to be a function"); } try { @@ -369,7 +386,7 @@ const createBehaviorMatchers = (actual: unknown): Matchers => ({ }, toHaveBeenCalled(): void { if (!isMockFunction(actual)) { - throw createAssertionError("Expected value to be a mock function"); + throw createUsageError("Expected value to be a mock function"); } if (actual.mock.calls.length === 0) { @@ -378,7 +395,7 @@ const createBehaviorMatchers = (actual: unknown): Matchers => ({ }, toHaveBeenCalledTimes(expected: number): void { if (!isMockFunction(actual)) { - throw createAssertionError("Expected value to be a mock function"); + throw createUsageError("Expected value to be a mock function"); } if (actual.mock.calls.length !== expected) { @@ -416,8 +433,12 @@ function createExpect(actual: unknown): ExpectResult { (...args: unknown[]): void => { try { matcher(...args); - } catch { - return; + } catch (error) { + if (error instanceof AssertionMismatch) { + return; + } + + throw error; } throw createAssertionError(`Expected value not to satisfy ${name}`); @@ -559,7 +580,7 @@ export const vi = { fn: createMock, restoreAllMocks: (): void => { for (const mockFn of registeredMocks) { - mockFn.mockClear(); + mockFn.mockReset(); } }, }; diff --git a/src/_internals/to-string-safe/to-string-safe.test.ts b/src/_internals/to-string-safe/to-string-safe.test.ts new file mode 100644 index 00000000..9a85898a --- /dev/null +++ b/src/_internals/to-string-safe/to-string-safe.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, expectTypeOf, it, test } from "../test/runtime"; +import { toStringSafe } from "./to-string-safe"; + +describe("toStringSafe", () => { + it("should read strings and numbers as String does", () => { + expect(toStringSafe("abc")).toBe("abc"); + expect(toStringSafe(123)).toBe("123"); + expect(toStringSafe(1.5)).toBe("1.5"); + expect(toStringSafe(12n)).toBe("12"); + }); + + it("should read arrays, booleans and plain objects as String does", () => { + expect(toStringSafe([1, 2])).toBe("1,2"); + expect(toStringSafe(true)).toBe("true"); + expect(toStringSafe({})).toBe("[object Object]"); + expect(toStringSafe(null)).toBe("null"); + // @ts-expect-error: intentionally missing argument + expect(toStringSafe()).toBe("undefined"); + }); + + it("should return an empty string for an object with a null prototype, which has no toString", () => { + expect(toStringSafe(Object.create(null))).toBe(""); + }); + + it("should return an empty string for an object whose toString throws", () => { + const hostile = { + toString: (): string => { + throw new Error("no"); + }, + }; + + expect(toStringSafe(hostile)).toBe(""); + }); +}); + +describe("toStringSafe types", () => { + test("should take unknown and return a string", () => { + expectTypeOf(toStringSafe).parameter(0).toEqualTypeOf(); + expectTypeOf(toStringSafe).returns.toEqualTypeOf(); + }); +}); diff --git a/src/_internals/to-string-safe/to-string-safe.ts b/src/_internals/to-string-safe/to-string-safe.ts new file mode 100644 index 00000000..ab911592 --- /dev/null +++ b/src/_internals/to-string-safe/to-string-safe.ts @@ -0,0 +1,22 @@ +/** + * Reads a value as a string the way `String(value)` does, but returns `""` when the value has no + * string conversion (an object with a null prototype, an object whose `toString` throws) instead + * of throwing, so a formatter handed hostile input never throws. + * + * @param {unknown} value - The value to read. + * @returns {string} `String(value)`, or `""` when that conversion throws. + * + * @example + * ```typescript + * toStringSafe(123) // "123" + * toStringSafe([1, 2]) // "1,2" + * toStringSafe(Object.create(null)) // "" + * ``` + */ +export const toStringSafe = (value: unknown): string => { + try { + return String(value); + } catch { + return ""; + } +}; diff --git a/src/add-business-days/add-business-days.test.ts b/src/add-business-days/add-business-days.test.ts index 9bcbc3ae..65513c76 100644 --- a/src/add-business-days/add-business-days.test.ts +++ b/src/add-business-days/add-business-days.test.ts @@ -1,68 +1,69 @@ import * as fc from "fast-check"; -import { type StateCode } from "../_internals/constants/states"; -import { businessDayDates } from "../_internals/test/arbitraries"; -import { expectNeverThrows } from "../_internals/test/properties"; +import { + anyBusinessDayAmount, + anyBusinessDayDate, + anyBusinessDayOptions, + businessDayDates, + PROTOTYPE_KEYS, +} from "../_internals/test/arbitraries"; +import { expectNeverThrowsWithArguments } from "../_internals/test/properties"; import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; -import { isBusinessDay } from "../is-business-day/is-business-day"; -import { addBusinessDays, type AddBusinessDaysParams } from "./add-business-days"; +import { type BusinessDayOptions, isBusinessDay } from "../is-business-day/is-business-day"; +import { addBusinessDays } from "./add-business-days"; describe("addBusinessDays", () => { it("should match the date-fns addBusinessDays example (10 business days from 2014-09-01 lands on 2014-09-15, https://date-fns.org/docs/addBusinessDays)", () => { - const result = addBusinessDays({ date: new Date(2014, 8, 1), days: 10 }); + const result = addBusinessDays(new Date(2014, 8, 1), 10); expect(result).toEqual(new Date(2014, 8, 15)); }); it("should skip a weekend when the very next day is a business day (Tue 2024-01-02 + 1 -> Wed 2024-01-03, noon)", () => { - const result = addBusinessDays({ date: new Date(2024, 0, 2, 12), days: 1 }); + const result = addBusinessDays(new Date(2024, 0, 2, 12), 1); expect(result).toEqual(new Date(2024, 0, 3, 12)); }); it("should skip Saturday and Sunday to land on the next Monday (Fri 2024-01-05 + 1)", () => { - const result = addBusinessDays({ date: new Date(2024, 0, 5, 12), days: 1 }); + const result = addBusinessDays(new Date(2024, 0, 5, 12), 1); expect(result).toEqual(new Date(2024, 0, 8, 12)); }); describe("supported years", () => { it("should return null when the date is outside 1900-2099 (Mon 2100-01-04)", () => { - expect(addBusinessDays({ date: new Date(2100, 0, 4, 12), days: 1 })).toBeNull(); + expect(addBusinessDays(new Date(2100, 0, 4, 12), 1)).toBeNull(); }); it("should return null when the walk leaves 2099 (Thu 2099-12-31 + 1) or 1900 (Tue 1900-01-02 - 1)", () => { - expect(addBusinessDays({ date: new Date(2099, 11, 31, 12), days: 1 })).toBeNull(); - expect(addBusinessDays({ date: new Date(1900, 0, 2, 12), days: -1 })).toBeNull(); + expect(addBusinessDays(new Date(2099, 11, 31, 12), 1)).toBeNull(); + expect(addBusinessDays(new Date(1900, 0, 2, 12), -1)).toBeNull(); }); it("should return null instead of looping when the date is the maximum representable Date", () => { - expect(addBusinessDays({ date: new Date(8.64e15), days: 1 })).toBeNull(); + expect(addBusinessDays(new Date(8.64e15), 1)).toBeNull(); }); it("should accept the inclusive boundary years 1900 and 2099", () => { - expect(addBusinessDays({ date: new Date(1900, 0, 2), days: 0 })).toEqual( - new Date(1900, 0, 2), - ); - expect(addBusinessDays({ date: new Date(2099, 0, 2), days: 0 })).toEqual( - new Date(2099, 0, 2), - ); + expect(addBusinessDays(new Date(1900, 0, 2), 0)).toEqual(new Date(1900, 0, 2)); + expect(addBusinessDays(new Date(2099, 0, 2), 0)).toEqual(new Date(2099, 0, 2)); }); - it("should return null for a date outside the supported range even when days is 0", () => { - expect(addBusinessDays({ date: new Date(2150, 0, 1), days: 0 })).toBeNull(); + it("should return null for a date outside the supported range even when the amount is 0", () => { + expect(addBusinessDays(new Date(2150, 0, 1), 0)).toBeNull(); }); }); describe("national holidays and year boundaries", () => { it("should skip Ano novo across a year boundary (2024-12-31 + 1 -> 2025-01-02)", () => { - const result = addBusinessDays({ date: new Date(2024, 11, 31, 12), days: 1 }); + const result = addBusinessDays(new Date(2024, 11, 31, 12), 1); expect(result).toEqual(new Date(2025, 0, 2, 12)); }); it("should treat 2025-01-01 (Ano novo) as a holiday, not counted towards the business days", () => { - const result = addBusinessDays({ date: new Date(2024, 11, 30, 12), days: 2 }); + const result = addBusinessDays(new Date(2024, 11, 30, 12), 2); expect(result).toEqual(new Date(2025, 0, 2, 12)); }); @@ -70,13 +71,13 @@ describe("addBusinessDays", () => { describe("state holidays", () => { it("should skip a state holiday when stateCode is provided (SP, Revolução Constitucionalista 2024-07-09)", () => { - const result = addBusinessDays({ date: new Date(2024, 6, 8, 12), days: 1, stateCode: "SP" }); + const result = addBusinessDays(new Date(2024, 6, 8, 12), 1, { stateCode: "SP" }); expect(result).toEqual(new Date(2024, 6, 10, 12)); }); - it("should not skip the same date when stateCode is not provided", () => { - const result = addBusinessDays({ date: new Date(2024, 6, 8, 12), days: 1 }); + it("should not skip the same date when no options are provided", () => { + const result = addBusinessDays(new Date(2024, 6, 8, 12), 1); expect(result).toEqual(new Date(2024, 6, 9, 12)); }); @@ -84,113 +85,115 @@ describe("addBusinessDays", () => { describe("includeOptional", () => { it("should skip Carnaval 2024-02-13 by default (includeOptional defaults to true)", () => { - const result = addBusinessDays({ date: new Date(2024, 1, 12, 12), days: 1 }); + const result = addBusinessDays(new Date(2024, 1, 12, 12), 1); expect(result).toEqual(new Date(2024, 1, 14, 12)); }); it("should count Carnaval 2024-02-13 as a business day when includeOptional is false", () => { - const result = addBusinessDays({ - date: new Date(2024, 1, 12, 12), - days: 1, - includeOptional: false, - }); + const result = addBusinessDays(new Date(2024, 1, 12, 12), 1, { includeOptional: false }); expect(result).toEqual(new Date(2024, 1, 13, 12)); }); }); - describe("negative days", () => { + describe("negative amounts", () => { it("should walk backwards, skipping weekends (Fri 2024-01-05 - 1 -> Thu 2024-01-04)", () => { - const result = addBusinessDays({ date: new Date(2024, 0, 5, 12), days: -1 }); + const result = addBusinessDays(new Date(2024, 0, 5, 12), -1); expect(result).toEqual(new Date(2024, 0, 4, 12)); }); it("should walk backwards across a weekend (Mon 2024-01-08 - 1 -> Fri 2024-01-05)", () => { - const result = addBusinessDays({ date: new Date(2024, 0, 8, 12), days: -1 }); + const result = addBusinessDays(new Date(2024, 0, 8, 12), -1); expect(result).toEqual(new Date(2024, 0, 5, 12)); }); }); - describe("days: 0", () => { + describe("an amount of 0", () => { it("should return a new Date equal to a business day input, unchanged", () => { const input = new Date(2024, 0, 2, 12); - const result = addBusinessDays({ date: input, days: 0 }); + const result = addBusinessDays(input, 0); expect(result).toEqual(new Date(2024, 0, 2, 12)); expect(result).not.toBe(input); }); it("should return the same calendar day even when it is a Saturday, mirroring date-fns' addBusinessDays(date, 0) behavior of not rolling to the next business day", () => { - const result = addBusinessDays({ date: new Date(2024, 0, 6, 12), days: 0 }); + const result = addBusinessDays(new Date(2024, 0, 6, 12), 0); expect(result).toEqual(new Date(2024, 0, 6, 12)); }); it("should return the same calendar day even when it is a holiday", () => { - const result = addBusinessDays({ date: new Date(2024, 0, 1, 12), days: 0 }); + const result = addBusinessDays(new Date(2024, 0, 1, 12), 0); expect(result).toEqual(new Date(2024, 0, 1, 12)); }); }); describe("invalid input", () => { - it("should return null when params is null", () => { + it("should return null when the date is null", () => { // @ts-expect-error: intentionally invalid input - expect(addBusinessDays(null)).toBeNull(); + expect(addBusinessDays(null, 1)).toBeNull(); }); - it("should return null when params is undefined", () => { + it("should return null when called without arguments", () => { // @ts-expect-error: intentionally invalid input expect(addBusinessDays()).toBeNull(); }); - it("should return null when params is not an object", () => { - // @ts-expect-error: intentionally invalid input - expect(addBusinessDays("2024-01-02")).toBeNull(); + it("should return null when the date is an invalid Date", () => { + expect(addBusinessDays(new Date("not a date"), 1)).toBeNull(); }); - it('should return null when params is a function, even one carrying date/days properties (typeof params !== "object" must reject it, not just isNullish)', () => { - const fakeParams = Object.assign(() => null, { date: new Date(2024, 0, 2), days: 1 }); + it("should return null when the date is not a Date", () => { + // @ts-expect-error: intentionally invalid input + expect(addBusinessDays("2024-01-02", 1)).toBeNull(); + }); - expect(addBusinessDays(fakeParams)).toBeNull(); + it("should return null when the amount is not an integer", () => { + expect(addBusinessDays(new Date(2024, 0, 2), 1.5)).toBeNull(); }); - it("should return null when date is an invalid Date", () => { - expect(addBusinessDays({ date: new Date("not a date"), days: 1 })).toBeNull(); + it("should return null when the amount is NaN", () => { + expect(addBusinessDays(new Date(2024, 0, 2), Number.NaN)).toBeNull(); }); - it("should return null when date is not a Date", () => { - // @ts-expect-error: intentionally invalid input - expect(addBusinessDays({ date: "2024-01-02", days: 1 })).toBeNull(); + it("should return null when the amount is Infinity", () => { + expect(addBusinessDays(new Date(2024, 0, 2), Number.POSITIVE_INFINITY)).toBeNull(); }); - it("should return null when days is not an integer", () => { - expect(addBusinessDays({ date: new Date(2024, 0, 2), days: 1.5 })).toBeNull(); + it("should return null when the amount is not a number", () => { + // @ts-expect-error: intentionally invalid input + expect(addBusinessDays(new Date(2024, 0, 2), "1")).toBeNull(); }); - it("should return null when days is NaN", () => { - expect(addBusinessDays({ date: new Date(2024, 0, 2), days: Number.NaN })).toBeNull(); + it("should return null when the stateCode is not a string", () => { + // @ts-expect-error: intentionally invalid input + expect(addBusinessDays(new Date(2024, 0, 2), 1, { stateCode: 123 })).toBeNull(); }); - it("should return null when days is Infinity", () => { - expect( - addBusinessDays({ date: new Date(2024, 0, 2), days: Number.POSITIVE_INFINITY }), - ).toBeNull(); + it("should return null when the stateCode is not a string even for an amount of 0, which walks no day", () => { + // @ts-expect-error: intentionally invalid input + expect(addBusinessDays(new Date(2024, 0, 2), 0, { stateCode: 123 })).toBeNull(); + // @ts-expect-error: intentionally invalid input + expect(addBusinessDays(new Date(2024, 0, 2), 0, { stateCode: null })).toBeNull(); }); - it("should return null when days is not a number", () => { + it("should ignore options that are not an object", () => { // @ts-expect-error: intentionally invalid input - expect(addBusinessDays({ date: new Date(2024, 0, 2), days: "1" })).toBeNull(); + expect(addBusinessDays(new Date(2024, 6, 8, 12), 1, "SP")).toEqual(new Date(2024, 6, 9, 12)); }); - it("should return null when stateCode is not a string", () => { - expect( - // @ts-expect-error: intentionally invalid input - addBusinessDays({ date: new Date(2024, 0, 2), days: 1, stateCode: 123 }), - ).toBeNull(); + it("should treat a prototype chain key as an unknown stateCode instead of throwing", () => { + for (const stateCode of PROTOTYPE_KEYS) { + expect( + // @ts-expect-error: intentionally invalid input + addBusinessDays(new Date(2024, 0, 2, 12), 1, { stateCode }), + ).toEqual(new Date(2024, 0, 3, 12)); + } }); }); @@ -198,13 +201,13 @@ describe("addBusinessDays", () => { const input = new Date(2024, 0, 2, 12); const before = input.getTime(); - addBusinessDays({ date: input, days: 5 }); + addBusinessDays(input, 5); expect(input.getTime()).toBe(before); }); it("should preserve the time-of-day of the input", () => { - const result = addBusinessDays({ date: new Date(2024, 0, 2, 9, 30, 15, 500), days: 1 }); + const result = addBusinessDays(new Date(2024, 0, 2, 9, 30, 15, 500), 1); expect(result?.getHours()).toBe(9); expect(result?.getMinutes()).toBe(30); @@ -213,18 +216,21 @@ describe("addBusinessDays", () => { }); describe("properties", () => { - const daysArbitrary = fc.integer({ min: -200, max: 200 }); + const amounts = fc.integer({ min: -200, max: 200 }); - test("should never throw, regardless of the input", () => { - expectNeverThrows(addBusinessDays, fc.anything()); + test("should never throw, regardless of the input, prototype chain state codes included", () => { + expectNeverThrowsWithArguments( + addBusinessDays, + fc.tuple(anyBusinessDayDate, anyBusinessDayAmount, anyBusinessDayOptions), + ); }); - test("should land on a business day whenever a non-zero number of days is requested", () => { + test("should land on a business day whenever a non-zero amount is requested", () => { fc.assert( - fc.property(businessDayDates, daysArbitrary, (date, days) => { - if (days === 0) return; + fc.property(businessDayDates, amounts, (date, amount) => { + if (amount === 0) return; - const result = addBusinessDays({ date, days }); + const result = addBusinessDays(date, amount); if (result !== null) { expect(isBusinessDay(result)).toBe(true); @@ -233,16 +239,16 @@ describe("addBusinessDays", () => { ); }); - test("should move the date forward for positive days and backward for negative days", () => { + test("should move the date forward for a positive amount and backward for a negative one", () => { fc.assert( - fc.property(businessDayDates, daysArbitrary, (date, days) => { - const result = addBusinessDays({ date, days }); + fc.property(businessDayDates, amounts, (date, amount) => { + const result = addBusinessDays(date, amount); if (result === null) return; - if (days > 0) { + if (amount > 0) { expect(result.getTime()).toBeGreaterThan(date.getTime()); - } else if (days < 0) { + } else if (amount < 0) { expect(result.getTime()).toBeLessThan(date.getTime()); } else { expect(result.getTime()).toBe(date.getTime()); @@ -254,14 +260,10 @@ describe("addBusinessDays", () => { }); describe("addBusinessDays types", () => { - test("should take an AddBusinessDaysParams and return a Date or null", () => { - expectTypeOf(addBusinessDays).parameter(0).toEqualTypeOf(); - expectTypeOf().toEqualTypeOf<{ - date: Date; - days: number; - stateCode?: StateCode; - includeOptional?: boolean; - }>(); + test("should take a Date, a number and optional BusinessDayOptions, and return a Date or null", () => { + expectTypeOf(addBusinessDays).parameter(0).toEqualTypeOf(); + expectTypeOf(addBusinessDays).parameter(1).toEqualTypeOf(); + expectTypeOf(addBusinessDays).parameter(2).toEqualTypeOf(); expectTypeOf(addBusinessDays).returns.toEqualTypeOf(); }); }); diff --git a/src/add-business-days/add-business-days.ts b/src/add-business-days/add-business-days.ts index a31d2be2..9847b11b 100644 --- a/src/add-business-days/add-business-days.ts +++ b/src/add-business-days/add-business-days.ts @@ -1,102 +1,91 @@ -import { HOLIDAYS_MAX_YEAR, HOLIDAYS_MIN_YEAR } from "../_internals/constants/holidays"; -import { type StateCode } from "../_internals/constants/states"; -import { isNullish } from "../_internals/is-nullish/is-nullish"; -import { isBusinessDay } from "../is-business-day/is-business-day"; +import { isSupportedHolidayYear } from "../_internals/is-supported-holiday-year/is-supported-holiday-year"; +import { type BusinessDayOptions, isBusinessDay } from "../is-business-day/is-business-day"; -/** The parameters `addBusinessDays` takes: the date to count from, how many business days to add and which holidays count. */ -export type AddBusinessDaysParams = { - /** The date to count from. Never mutated: a new `Date` is returned. */ - date: Date; - /** Number of business days to add; a negative value walks backwards. Must be a finite integer. */ - days: number; - /** Two letter state code whose state holidays are also treated as non-business days (default: national holidays only). */ - stateCode?: StateCode; - /** Whether optional-type holidays (e.g. Carnaval, Corpus Christi) count as non-business days (default: `true`, matching Brazilian banking practice). */ - includeOptional?: boolean; -}; - -const isSupportedYear = (date: Date): boolean => { - const year = date.getFullYear(); - - return year >= HOLIDAYS_MIN_YEAR && year <= HOLIDAYS_MAX_YEAR; -}; +export type { BusinessDayOptions } from "../is-business-day/is-business-day"; /** * Adds a number of Brazilian business days (dias úteis) to a date. * * A business day is a day for which `isBusinessDay` returns `true` (not a Saturday, a - * Sunday, or a Brazilian holiday), evaluated with the same `stateCode`/`includeOptional` - * options. The function walks one calendar day at a time, in the direction of `days`, - * counting only business days, so it is exact regardless of the arrangement of holidays - * around `date` (cheap in practice: `getHolidays` is memoized per year). + * Sunday, or a Brazilian holiday), evaluated with the same `options`. The function walks one + * calendar day at a time, in the direction of `amount`, counting only business days, so it is + * exact regardless of the arrangement of holidays around `date` (cheap in practice: + * `getHolidays` is memoized per year). * - * `days: 0` returns a **new `Date` equal to `date`, unchanged**, even when `date` itself + * `amount: 0` returns a **new `Date` equal to `date`, unchanged**, even when `date` itself * falls on a weekend or holiday. This mirrors the verified behavior of date-fns' * `addBusinessDays(date, 0)`, which also returns the input date as-is rather than rolling - * it to the next business day; see `@see` below. A negative `days` walks backwards, one - * business day at a time, exactly like date-fns. + * it to the next business day; see `@see` below. A negative `amount` walks backwards, one + * business day at a time, exactly like date-fns; `subBusinessDays` is the same walk spelled + * positively. * * The time-of-day (hours, minutes, seconds, milliseconds) of `date` is preserved in the * result, and `date` itself is never mutated. * - * If `stateCode` is provided but is not a valid/known state code, it is ignored and only - * national holidays are considered (same behavior as `getHolidays`/`isBusinessDay`). + * If `options.stateCode` is provided but is not a valid/known state code, it is ignored and + * only national holidays are considered (same behavior as `getHolidays`/`isBusinessDay`), so a + * prototype-chain key such as `"__proto__"` is an unknown state code like any other. An + * `options` that is not an object at all is ignored, exactly as `isBusinessDay` ignores it. * * Only years from 1900 through 2099 are supported, the range `getHolidays` computes. A `date` * outside it, or a walk that leaves it, returns `null`. * - * @param {AddBusinessDaysParams} params - The parameters for the calculation. - * @param {Date} params.date - The date to count from. - * @param {number} params.days - The number of business days to add (negative to subtract). - * @param {StateCode} [params.stateCode] - Brazilian state code whose state holidays are also considered. - * @param {boolean} [params.includeOptional] - Whether optional holidays count as non-business days (default: `true`). - * @returns {Date | null} A new `Date`, `days` business days after `date`. `null` on bad - * input: a `params` that is not an object, a `date` that is not a valid `Date` or is outside - * 1900-2099, a `days` that is not a finite integer, a `stateCode` that is not a string, or a - * walk that leaves the supported years. + * @param {Date} date - The date to count from. Never mutated: a new `Date` is returned. + * @param {number} amount - The number of business days to add; a negative value walks backwards. + * @param {BusinessDayOptions} [options] - Which holidays count as non-business days. + * @param {StateCode} [options.stateCode] - Brazilian state code whose state holidays are also considered. + * @param {boolean} [options.includeOptional] - Whether optional holidays count as non-business days (default: `true`). + * @returns {Date | null} A new `Date`, `amount` business days after `date`. `null` on bad + * input: a `date` that is not a valid `Date` or is outside 1900-2099, an `amount` that is not a + * finite integer, a `stateCode` that is not a string, or a walk that leaves the supported years. * * @example * ```typescript - * addBusinessDays({ date: new Date(2024, 0, 2, 12), days: 1 }); // Wed 2024-01-03, 12:00 (the next day is already a business day) - * addBusinessDays({ date: new Date(2024, 11, 31, 12), days: 1 }); // Thu 2025-01-02, 12:00 (Jan 1 is Ano novo, skipped) - * addBusinessDays({ date: new Date(2024, 0, 5, 12), days: -1 }); // Thu 2024-01-04, 12:00 (walks backwards) - * addBusinessDays({ date: new Date(2024, 0, 6, 12), days: 0 }); // Sat 2024-01-06, 12:00 (unchanged, even though Saturday is not a business day) - * addBusinessDays({ date: new Date("not a date"), days: 1 }); // null - * addBusinessDays({ date: new Date(2024, 0, 2), days: 1.5 }); // null (not an integer) - * addBusinessDays({ date: new Date(2099, 11, 31), days: 1 }); // null (the walk leaves the supported years) - * addBusinessDays(null); // null + * addBusinessDays(new Date(2024, 0, 2, 12), 1); // Wed 2024-01-03, 12:00 (the next day is already a business day) + * addBusinessDays(new Date(2024, 11, 31, 12), 1); // Thu 2025-01-02, 12:00 (Jan 1 is Ano novo, skipped) + * addBusinessDays(new Date(2024, 0, 5, 12), -1); // Thu 2024-01-04, 12:00 (walks backwards) + * addBusinessDays(new Date(2024, 0, 6, 12), 0); // Sat 2024-01-06, 12:00 (unchanged, even though Saturday is not a business day) + * addBusinessDays(new Date(2024, 6, 8, 12), 1, { stateCode: "SP" }); // Wed 2024-07-10, 12:00 (Jul 9 is a state holiday in SP) + * addBusinessDays(new Date("not a date"), 1); // null + * addBusinessDays(new Date(2024, 0, 2), 1.5); // null (not an integer) + * addBusinessDays(new Date(2099, 11, 31), 1); // null (the walk leaves the supported years) + * addBusinessDays(null, 1); // null * ``` * - * @see Based on: https://date-fns.org/docs/addBusinessDays Reference behavior for `days: 0` and - * for walking backwards on a negative `days`. The underlying holiday determination's official - * sources are cited in `isBusinessDay`/`getHolidays`. + * @see Based on: https://date-fns.org/docs/addBusinessDays + * Reference behavior for `amount: 0`, + * for the positional `(date, amount)` argument order and for walking backwards on a negative + * `amount`. The underlying holiday determination's official sources are cited in + * `isBusinessDay`/`getHolidays`. */ -export const addBusinessDays = (params: AddBusinessDaysParams): Date | null => { - if (isNullish(params) || typeof params !== "object") return null; - - const { date, days, stateCode, includeOptional } = params; - +export const addBusinessDays = ( + date: Date, + amount: number, + options?: BusinessDayOptions, +): Date | null => { if (!(date instanceof Date) || Number.isNaN(date.getTime())) return null; - if (!Number.isInteger(days)) return null; + if (!Number.isInteger(amount)) return null; + + const stateCode = options?.stateCode; if (stateCode !== undefined && typeof stateCode !== "string") return null; - if (!isSupportedYear(date)) return null; + if (!isSupportedHolidayYear(date.getFullYear())) return null; const result = new Date(date); const hours = result.getHours(); - // Stryker disable next-line EqualityOperator: when days is 0, remaining is 0 below and the loop never reads step, so > vs >= here is unobservable - const step = days > 0 ? 1 : -1; - let remaining = Math.abs(days); + // Stryker disable next-line EqualityOperator: when amount is 0, remaining is 0 below and the loop never reads step, so > vs >= here is unobservable + const step = amount > 0 ? 1 : -1; + let remaining = Math.abs(amount); while (remaining > 0) { result.setDate(result.getDate() + step); - if (!isSupportedYear(result)) return null; + if (!isSupportedHolidayYear(result.getFullYear())) return null; - if (isBusinessDay(result, { stateCode, includeOptional })) { + if (isBusinessDay(result, options)) { remaining -= 1; } } diff --git a/src/capitalize/capitalize.test.ts b/src/capitalize/capitalize.test.ts index 8aa6fc9d..e79314dc 100644 --- a/src/capitalize/capitalize.test.ts +++ b/src/capitalize/capitalize.test.ts @@ -1,7 +1,10 @@ import * as fc from "fast-check"; +import { DATA } from "../_internals/constants/states"; +import { expectNeverThrowsWithOptions } from "../_internals/test/properties"; import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; import { capitalize, type CapitalizeOptions } from "./capitalize"; +import { STATE_CODES } from "./constants"; describe("capitalize", () => { describe("should capitalize", () => { @@ -43,11 +46,117 @@ describe("capitalize", () => { }); test("when upper case words are provided in any case", () => { - expect(capitalize("empresa ltda")).toBe("Empresa Ltda"); expect(capitalize("empresa ltda", { upperCaseWords: ["ltda"] })).toBe("Empresa LTDA"); expect(capitalize("meu cpf e rg", { upperCaseWords: ["CPF", "Rg"] })).toBe("Meu CPF e RG"); }); + test("when the value is a Brazilian personal name", () => { + expect(capitalize("jose da silva")).toBe("Jose da Silva"); + expect(capitalize("JOSÉ DA SILVA")).toBe("José da Silva"); + expect(capitalize("de")).toBe("De"); + }); + + test("when the value carries a company designation, upper cased by default", () => { + expect(capitalize("empresa ltda")).toBe("Empresa LTDA"); + expect(capitalize("banco do brasil s.a.")).toBe("Banco do Brasil S.A."); + expect(capitalize("casa de carnes s/a")).toBe("Casa de Carnes S/A"); + expect(capitalize("casa de carnes s/a comércio")).toBe("Casa de Carnes S/A Comércio"); + expect(capitalize("consultoria s/s")).toBe("Consultoria S/S"); + expect(capitalize("padaria e confeitaria me")).toBe("Padaria e Confeitaria ME"); + expect(capitalize("meu cpf e rg")).toBe("Meu CPF e RG"); + expect(capitalize("cep 01310-100")).toBe("CEP 01310-100"); + }); + + test("when a word looks like a designation but is not one, or is a designation left out of the default list", () => { + expect(capitalize("jose de sa")).toBe("Jose de Sa"); + expect(capitalize("eu vi maria")).toBe("Eu Vi Maria"); + expect(capitalize("diga-me")).toBe("Diga-ME"); + }); + + test("when the value is a Brazilian address", () => { + expect(capitalize("mogi-guaçu")).toBe("Mogi-Guaçu"); + expect(capitalize("santana/rs")).toBe("Santana/RS"); + expect(capitalize("porto alegre/rs")).toBe("Porto Alegre/RS"); + expect(capitalize("são paulo/sp")).toBe("São Paulo/SP"); + }); + + test("when a word is bound by an apostrophe or by punctuation", () => { + expect(capitalize("santa bárbara d'oeste")).toBe("Santa Bárbara d'Oeste"); + expect(capitalize("SANTA BÁRBARA D'OESTE")).toBe("Santa Bárbara d'Oeste"); + expect(capitalize("joão d’ávila")).toBe("João d’Ávila"); + expect(capitalize("o'neill")).toBe("O'Neill"); + expect(capitalize("(empresa) ltda")).toBe("(Empresa) LTDA"); + expect(capitalize('"joão" silva')).toBe('"João" Silva'); + expect(capitalize("bairro:centro")).toBe("Bairro:Centro"); + expect(capitalize("rua b,número 10")).toBe("Rua B,Número 10"); + expect(capitalize("casa;lote [3]")).toBe("Casa;Lote [3]"); + }); + + test("when a single letter follows an apostrophe, the English possessive, which stays in lower case", () => { + expect(capitalize("bob's")).toBe("Bob's"); + expect(capitalize("habib's")).toBe("Habib's"); + expect(capitalize("mc donald's")).toBe("Mc Donald's"); + expect(capitalize("x'd")).toBe("X'd"); + expect(capitalize("sant'ana")).toBe("Sant'Ana"); + }); + + test("when the elided particle d' is followed by an apostrophe and a word, wherever it appears", () => { + expect(capitalize("d'oeste")).toBe("d'Oeste"); + expect(capitalize("dias d'ávila")).toBe("Dias d'Ávila"); + expect(capitalize("olho d'água do piauí")).toBe("Olho d'Água do Piauí"); + expect(capitalize("rua d'")).toBe("Rua D'"); + expect(capitalize("d''oeste")).toBe("D''Oeste"); + }); + + test("when a word of the lower case list ends the value or is followed by punctuation, so it is a designator rather than a link between two words", () => { + expect(capitalize("rua d")).toBe("Rua D"); + expect(capitalize("rua a, 100")).toBe("Rua A, 100"); + expect(capitalize("condomínio a, quadra d, lote o")).toBe("Condomínio A, Quadra D, Lote O"); + expect(capitalize("maria e joão")).toBe("Maria e João"); + expect(capitalize("maria e--joão")).toBe("Maria e--João"); + expect(capitalize("josé da silva")).toBe("José da Silva"); + expect(capitalize("de")).toBe("De"); + expect(capitalize("luiz von schmidt")).toBe("Luiz von Schmidt"); + expect(capitalize("são joão del rei")).toBe("São João del Rei"); + }); + + test("when ME is the pronoun rather than the designation of a microempresa, which is written at the end of the name", () => { + expect(capitalize("fulano comércio me")).toBe("Fulano Comércio ME"); + expect(capitalize("fulano me epp")).toBe("Fulano ME EPP"); + expect(capitalize("fulano ltda me")).toBe("Fulano LTDA ME"); + expect(capitalize("não-me-toque")).toBe("Não-Me-Toque"); + expect(capitalize("diga-me a verdade")).toBe("Diga-Me a Verdade"); + }); + + test("when the name carries a foreign particle", () => { + expect(capitalize("luiz von schmidt")).toBe("Luiz von Schmidt"); + expect(capitalize("maria van der berg")).toBe("Maria van der Berg"); + expect(capitalize("são joão del rei")).toBe("São João del Rei"); + expect(capitalize("carlo di giovanni")).toBe("Carlo di Giovanni"); + expect(capitalize("von schmidt")).toBe("Von Schmidt"); + }); + + test("when a word after a slash is not a state code, and when a state code has no slash before it", () => { + expect(capitalize("santana/br")).toBe("Santana/Br"); + expect(capitalize("santana/xingu")).toBe("Santana/Xingu"); + expect(capitalize("santana rs")).toBe("Santana Rs"); + }); + + test("when the value carries a roman numeral", () => { + expect(capitalize("joão paulo ii")).toBe("João Paulo II"); + expect(capitalize("rua xv de novembro")).toBe("Rua XV de Novembro"); + expect(capitalize("avenida papa joão xxiii")).toBe("Avenida Papa João XXIII"); + }); + + test("when a word list given in the options replaces the default one", () => { + expect(capitalize("empresa ltda", { upperCaseWords: [] })).toBe("Empresa Ltda"); + expect(capitalize("jose da silva", { lowerCaseWords: [] })).toBe("Jose Da Silva"); + expect(capitalize("banco do brasil s.a.", { upperCaseWords: ["s.a."] })).toBe( + "Banco do Brasil S.A.", + ); + expect(capitalize("santana/rs", { upperCaseWords: [] })).toBe("Santana/RS"); + }); + test("when the value contains whitespace other than a space", () => { expect(capitalize("joao\tsilva")).toBe("Joao Silva"); expect(capitalize("joao\n\nsilva")).toBe("Joao Silva"); @@ -56,7 +165,7 @@ describe("capitalize", () => { test("when the value contains hyphens or slashes", () => { expect(capitalize("MOGI-GUAÇU")).toBe("Mogi-Guaçu"); - expect(capitalize("SANTANA/RS")).toBe("Santana/Rs"); + expect(capitalize("SANTANA/RS")).toBe("Santana/RS"); expect(capitalize("SANTANA/RS", { upperCaseWords: ["rs"] })).toBe("Santana/RS"); expect(capitalize("sÃo josÉ do rio-preto")).toBe("São José do Rio-Preto"); expect(capitalize("de-facto")).toBe("De-Facto"); @@ -83,13 +192,44 @@ describe("capitalize", () => { expect(capitalize(123)).toBe(""); }); + describe("should fall back to the defaults when a word list is malformed", () => { + test("when the word list is not an array", () => { + // @ts-expect-error: intentionally invalid input + expect(capitalize("jose da silva", { lowerCaseWords: null })).toBe("Jose da Silva"); + // @ts-expect-error: intentionally invalid input + expect(capitalize("jose da silva", { upperCaseWords: null })).toBe("Jose da Silva"); + // @ts-expect-error: intentionally invalid input + expect(capitalize("jose da silva", { lowerCaseWords: "ab" })).toBe("Jose da Silva"); + // @ts-expect-error: intentionally invalid input + expect(capitalize("jose da silva", { upperCaseWords: 1 })).toBe("Jose da Silva"); + }); + + test("when the word list holds a value that is not a string", () => { + // @ts-expect-error: intentionally invalid input + expect(capitalize("jose da silva", { lowerCaseWords: [null] })).toBe("Jose Da Silva"); + // @ts-expect-error: intentionally invalid input + expect(capitalize("jose da silva", { upperCaseWords: [1] })).toBe("Jose da Silva"); + // @ts-expect-error: intentionally invalid input + expect(capitalize("jose da silva", { lowerCaseWords: [1, "da"] })).toBe("Jose da Silva"); + }); + }); + + test("should keep its state code list in sync with the one published by the IBGE", () => { + expect(STATE_CODES).toStrictEqual(DATA.map((state) => state.code)); + }); + describe("properties", () => { + const nulls = fc.constant(null); + const wordListMembers = fc.oneof(fc.string(), fc.integer(), nulls); + const wordLists = fc.oneof(nulls, fc.string(), fc.integer(), fc.array(wordListMembers)); + const optionRecord = fc.record( + { lowerCaseWords: wordLists, upperCaseWords: wordLists }, + { requiredKeys: [] }, + ); + const hostileOptions = fc.oneof(fc.anything(), optionRecord); + test("should never throw, regardless of the input", () => { - fc.assert( - fc.property(fc.anything(), (value) => { - expect(() => capitalize(value as never)).not.toThrow(); - }), - ); + expectNeverThrowsWithOptions(capitalize, fc.anything(), hostileOptions); }); test("should be idempotent on its own output", () => { diff --git a/src/capitalize/capitalize.ts b/src/capitalize/capitalize.ts index 70d238fb..bf362957 100644 --- a/src/capitalize/capitalize.ts +++ b/src/capitalize/capitalize.ts @@ -1,84 +1,262 @@ -import { PREPOSITIONS, SEPARATOR_REGEX, WHITESPACE_REGEX } from "./constants"; +import { + APOSTROPHE_REGEX, + ELIDED_PARTICLE, + JOINER_REGEX, + PREPOSITIONS, + PUNCTUATION_REGEX, + SEPARATOR_REGEX, + STATE_CODES, + TRAILING_DESIGNATIONS, + UPPER_CASE_WORDS, + WHITESPACE_REGEX, + WORD_REGEX, +} from "./constants"; /** Options of `capitalize`. */ export type CapitalizeOptions = { /** Words to keep in lower case when they are not the first word (default: the Portuguese prepositions). */ lowerCaseWords?: string[]; - /** Words to keep in upper case wherever they appear (default: `[]`). */ + /** Words to keep in upper case wherever they appear (default: the Brazilian company designations, document abbreviations and roman numerals). */ upperCaseWords?: string[]; }; +const stateCodeSet: Set = new Set(STATE_CODES); + +const trailingDesignationSet: Set = new Set(TRAILING_DESIGNATIONS); + +const toWordSet = ( + words: unknown, + fallback: readonly string[], + fold: (word: string) => string, +): Set => { + const source: readonly unknown[] = Array.isArray(words) ? words : fallback; + + return new Set(source.filter((word) => typeof word === "string").map((word) => fold(word))); +}; + /** - * Capitalizes a given string according to specific rules for lower-case and upper-case words. + * A token that carries a word, as opposed to a separator or the empty token between two of them. * - * Words are separated by whitespace, by `-` and by `/`, so `"MOGI-GUAÇU"` becomes - * `"Mogi-Guaçu"` and `"SANTANA/RS"` becomes `"Santana/Rs"`. Hyphens and slashes are kept - * where they are, while every run of whitespace (spaces, tabs, newlines) collapses into a - * single space and the leading and trailing whitespace is dropped. + * @param {string} token - The token to classify. + * @returns {boolean} `true` when the token is a word. + */ +const isWord = (token: string): boolean => WORD_REGEX.test(token); + +/** + * An apostrophe token, the one that elides the particle of `d'Oeste` and marks the possessive of + * `Bob's`. * - * - Words listed in `lowerCaseWords` (default: `PREPOSITIONS`) will be converted to lower case, except for the first word. - * - Words listed in `upperCaseWords` will be converted to upper case (none by default). The - * comparison ignores the case of the words given in both lists. - * - All other words will be capitalized (first letter upper case, rest lower case). + * @param {string} token - The token to classify. + * @returns {boolean} `true` when the token is an apostrophe. + */ +const isApostrophe = (token: string): boolean => APOSTROPHE_REGEX.test(token); + +/** + * The next word of the value after `index`, plus whether it is joined to the word at `index`, that + * is, whether only whitespace, `-`, `/` or an apostrophe stands between the two. + * + * @param {string[]} tokens - Every token of the value, words and separators alike. + * @param {number} index - The index of the word to look ahead from. + * @returns {{ joined: boolean; next: string }} The next word (`""` when there is none) and whether it is joined to the word at `index`. + */ +const lookAhead = (tokens: string[], index: number): { joined: boolean; next: string } => { + let joined = true; + + for (let position = index + 1; position < tokens.length; position++) { + const token = tokens[position]; + + if (token === "") continue; + if (isWord(token)) return { joined, next: token }; + if (!JOINER_REGEX.test(token)) joined = false; + } + + return { joined: false, next: "" }; +}; + +/** + * The `d` of `d'Oeste`: an elided particle only when an apostrophe and a word follow it. Splitting + * on the separators always leaves a token after each of them, so the token two places ahead of a + * word followed by an apostrophe is always there, even when it is the empty one of `"rua d'"`. + * + * @param {string[]} tokens - Every token of the value, words and separators alike. + * @param {number} index - The index of the word being written. + * @param {string} word - That word, in lower case. + * @returns {boolean} `true` when the word is the elided particle. + */ +const isElidedParticle = (tokens: string[], index: number, word: string): boolean => + word === ELIDED_PARTICLE && isApostrophe(tokens[index + 1]) && isWord(tokens[index + 2]); + +/** + * The `s` of `Bob's`: the English possessive, a single letter written right after an apostrophe. + * + * @param {string[]} tokens - Every token of the value, words and separators alike. + * @param {number} index - The index of the word being written. + * @param {string} word - That word, in lower case. + * @returns {boolean} `true` when the word is an English possessive. + */ +const isPossessive = (tokens: string[], index: number, word: string): boolean => + word.length === 1 && isApostrophe(tokens[index - 1]); + +/** + * Whether a word of the upper case list stands where it is written in upper case. Every + * designation but the ones of `TRAILING_DESIGNATIONS` is upper case wherever it appears; those + * are upper case only as the last word of the value or right before another designation. + * + * @param {string} word - The word being written, in upper case. + * @param {string} next - The next word of the value, `""` when the word is the last one. + * @param {Set} upperCaseSet - The upper case word list in force. + * @returns {boolean} `true` when the word is written in upper case where it stands. + */ +const isUpperCasePosition = (word: string, next: string, upperCaseSet: Set): boolean => + !trailingDesignationSet.has(word) || + next === "" || + upperCaseSet.has(next.toLocaleUpperCase("pt-BR")); + +/** + * Capitalizes a given string according to the way a Brazilian name, company name or address is + * written, with no configuration needed: `"jose da silva"` becomes `"Jose da Silva"`, + * `"empresa ltda"` becomes `"Empresa LTDA"` and `"santana/rs"` becomes `"Santana/RS"`. + * + * Words are separated by whitespace, by `-` and `/`, by the apostrophe (`"d'oeste"` becomes + * `"d'Oeste"`) and by punctuation that touches a word (`"(empresa)"` becomes `"(Empresa)"`, + * `"bairro:centro"` becomes `"Bairro:Centro"`), so `"MOGI-GUAÇU"` becomes `"Mogi-Guaçu"`. The + * separators are kept where they are, while every run of whitespace (spaces, tabs, newlines) + * collapses into a single space and the leading and trailing whitespace is dropped. The particles + * of foreign-origin names (`del`, `della`, `di`, `du`, `van`, `von`, `der`, `den`) stay lower + * case like the Portuguese prepositions, so `"luiz von schmidt"` becomes `"Luiz von Schmidt"`. + * + * - Words listed in `lowerCaseWords` are converted to lower case when they link two words, that + * is, when they are neither the first word nor the last one and another word follows them + * across whitespace, `-`, `/` or an apostrophe. The default list is the Portuguese + * prepositions, articles and conjunctions that stay in lower case inside a proper name ("de", + * "da", "do", "e", ...), so `"JOSÉ DA SILVA"` becomes `"José da Silva"`. A word of the list + * that ends the value or is followed by punctuation is a designator instead, and keeps its + * capital: `"rua a, 100"` becomes `"Rua A, 100"` and `"condomínio a, quadra d, lote o"` becomes + * `"Condomínio A, Quadra D, Lote O"`. + * - The elided particle `d'` is written in lower case wherever it appears, including as the first + * word, but only when an apostrophe and a word follow it, so `"santa bárbara d'oeste"` becomes + * `"Santa Bárbara d'Oeste"` and `"dias d'ávila"` becomes `"Dias d'Ávila"` while the designator + * `"rua d"` becomes `"Rua D"`. A single letter written right after an apostrophe is the English + * possessive and stays in lower case, so `"bob's"` becomes `"Bob's"`, not `"Bob'S"`. + * - Words listed in `upperCaseWords` are converted to upper case wherever they appear. The + * default list is the company designations and document abbreviations that are written in upper + * case in Brazilian usage (`LTDA`, `S.A.`, `S/A`, `S.S.`, `S/S`, `ME`, `EPP`, `MEI`, `EIRELI`, + * `CIA`, `SCP`, `CNPJ`, `CPF`, `RG`, `CEP`, `UF`) plus the roman numerals that appear in names + * and addresses (`II` through `XXIII`, except `VI`, so `"joão paulo ii"` becomes + * `"João Paulo II"` and `"rua xv de novembro"` becomes `"Rua XV de Novembro"`). `ME` is also + * the pt-BR pronoun "me", so it is only upper cased in the designation position, as the last + * word of the value (`"fulano comércio me"` becomes `"Fulano Comércio ME"`) or right before + * another designation (`"fulano me epp"` becomes `"Fulano ME EPP"`); anywhere else it is an + * ordinary word, so `"diga-me a verdade"` becomes `"Diga-Me a Verdade"` and the municipality + * `"não-me-toque"` becomes `"Não-Me-Toque"`. A designation + * written around a slash, `S/A` and `S/S`, is matched across that slash even though a slash + * separates words, so `"casa de carnes s/a"` becomes `"Casa de Carnes S/A"`. + * - A two letter word that follows a `/` is converted to upper case when it is the code of a + * Brazilian state, the way a municipality and its Federative Unit are written together, so + * `"porto alegre/rs"` becomes `"Porto Alegre/RS"` while `"santana/br"` becomes `"Santana/Br"`. + * A state code that does not follow a `/` is left alone (`"santana rs"` becomes + * `"Santana Rs"`), and so is any other two letter word. + * - All other words are capitalized (first letter upper case, rest lower case). + * + * Both lists are compared ignoring the case of the words, and either one given in `options` + * replaces its default list entirely, so `capitalize("empresa ltda", { upperCaseWords: [] })` + * gives `"Empresa Ltda"`. A `lowerCaseWords`/`upperCaseWords` that is not an array falls back to + * its default, and a member of either list that is not a string is ignored, so a malformed + * option never throws. * * @param {string} value - The input string to be capitalized. * @param {CapitalizeOptions} [options] - Optional configuration for capitalization. - * @param {string[]} [options.lowerCaseWords] - Array of words to keep in lower case (default: `PREPOSITIONS`). - * @param {string[]} [options.upperCaseWords] - Array of words to keep in upper case (default: `[]`). + * @param {string[]} [options.lowerCaseWords] - Array of words to keep in lower case (default: the Portuguese prepositions). + * @param {string[]} [options.upperCaseWords] - Array of words to keep in upper case (default: the Brazilian company designations, document abbreviations and roman numerals). * @returns {string} The capitalized string according to the specified rules. * + * The default `lowerCaseWords` list is the set of prepositions and conjunctions the Manual de + * Redação da Presidência da República keeps in lower case inside a proper name, and the default + * `upperCaseWords` list is sourced in `constants.ts` from the laws that create each designation. + * + * @see Official: https://www4.planalto.gov.br/centrodeestudos/assuntos/manual-de-redacao-da-presidencia-da-republica/manual-de-redacao.pdf + * Manual de Redação da Presidência da República, 3ª edição (Portaria nº 1.369/2018), item 5.1.8 + * b) and item 10.2 a). + * @see Official: https://www4.planalto.gov.br/centrodeestudos/assuntos/manual-de-redacao-da-presidencia-da-republica + * The Presidência page that publishes it. + * * @example * ```typescript * capitalize("JOSÉ DA SILVA"); // "José da Silva" - * capitalize("empresa ltda"); // "Empresa Ltda" - * capitalize("empresa ltda", { upperCaseWords: ["ltda"] }); // "Empresa LTDA" + * capitalize("empresa ltda"); // "Empresa LTDA" + * capitalize("banco do brasil s.a."); // "Banco do Brasil S.A." + * capitalize("santa bárbara d'oeste"); // "Santa Bárbara d'Oeste" + * capitalize("bob's"); // "Bob's" + * capitalize("rua a, 100"); // "Rua A, 100" + * capitalize("fulano comércio me"); // "Fulano Comércio ME" + * capitalize("não-me-toque"); // "Não-Me-Toque" + * capitalize("(empresa) ltda"); // "(Empresa) LTDA" + * capitalize("luiz von schmidt"); // "Luiz von Schmidt" + * capitalize("casa de carnes s/a"); // "Casa de Carnes S/A" * capitalize("MOGI-GUAÇU"); // "Mogi-Guaçu" - * capitalize("SANTANA/RS"); // "Santana/Rs" - * capitalize("SANTANA/RS", { upperCaseWords: ["rs"] }); // "Santana/RS" + * capitalize("santana/rs"); // "Santana/RS" + * capitalize("rua xv de novembro"); // "Rua XV de Novembro" + * capitalize("empresa ltda", { upperCaseWords: [] }); // "Empresa Ltda" * capitalize("joao\tsilva"); // "Joao Silva" * ``` */ export const capitalize = (value: string, options?: CapitalizeOptions): string => { if (typeof value !== "string") return ""; - // Stryker disable next-line ArrayDeclaration: the default is never compared against multi-word placeholder content, so any non-empty placeholder array stays unmatched and behaviorally identical - const { lowerCaseWords = PREPOSITIONS, upperCaseWords = [] } = options ?? {}; + const { lowerCaseWords, upperCaseWords } = options ?? {}; - const lowerCaseSet = new Set(lowerCaseWords.map((word) => word.toLocaleLowerCase("pt-BR"))); + const lowerCaseSet = toWordSet(lowerCaseWords, PREPOSITIONS, (word) => + word.toLocaleLowerCase("pt-BR"), + ); - const upperCaseSet = new Set(upperCaseWords.map((word) => word.toLocaleUpperCase("pt-BR"))); + const upperCaseSet = toWordSet(upperCaseWords, UPPER_CASE_WORDS, (word) => + word.toLocaleUpperCase("pt-BR"), + ); const tokens = value.trim().split(SEPARATOR_REGEX); - let result = ""; + const output: string[] = []; let wordIndex = 0; - for (const token of tokens) { + for (const [index, token] of tokens.entries()) { if (!token) continue; if (WHITESPACE_REGEX.test(token)) { - result += " "; + output.push(" "); continue; } - if (token === "-" || token === "/") { - result += token; + if (PUNCTUATION_REGEX.test(token)) { + output.push(token); continue; } const lowerCaseWord = token.toLocaleLowerCase("pt-BR"); const upperCaseWord = token.toLocaleUpperCase("pt-BR"); + const designation = (output.slice(-2).join("") + upperCaseWord).toLocaleUpperCase("pt-BR"); + const { joined, next } = lookAhead(tokens, index); - if (wordIndex > 0 && lowerCaseSet.has(lowerCaseWord)) { - result += lowerCaseWord; - } else if (upperCaseSet.has(upperCaseWord)) { - result += upperCaseWord; + if (upperCaseSet.has(designation)) { + output.splice(-2, 2, designation); + } else if (isPossessive(tokens, index, lowerCaseWord)) { + output.push(lowerCaseWord); + } else if (isElidedParticle(tokens, index, lowerCaseWord)) { + output.push(lowerCaseWord); + } else if (wordIndex > 0 && joined && lowerCaseSet.has(lowerCaseWord)) { + output.push(lowerCaseWord); + } else if ( + upperCaseSet.has(upperCaseWord) && + isUpperCasePosition(upperCaseWord, next, upperCaseSet) + ) { + output.push(upperCaseWord); + } else if (output.at(-1) === "/" && stateCodeSet.has(upperCaseWord)) { + output.push(upperCaseWord); } else { - result += upperCaseWord.charAt(0) + lowerCaseWord.slice(1); + output.push(upperCaseWord.charAt(0) + lowerCaseWord.slice(1)); } wordIndex++; } - return result; + return output.join(""); }; diff --git a/src/capitalize/constants.ts b/src/capitalize/constants.ts index 081cd985..76a99d18 100644 --- a/src/capitalize/constants.ts +++ b/src/capitalize/constants.ts @@ -1,3 +1,26 @@ +import { type StateCode } from "../_internals/constants/states"; + +/** + * Prepositions, articles and conjunctions that stay in lower case inside a proper name, the + * default `lowerCaseWords` of `capitalize`. The Manual de Redação da Presidência da República + * states the convention twice: a cargo is "redigido apenas com as iniciais maiúsculas. As + * preposições que liguem as palavras do cargo devem ser grafadas em minúsculas" (item 5.1.8 b), + * and a title is written "com inicial maiúscula em todas as palavras, exceto nas de ligação" + * (item 10.2 a). The same convention is used by the IBGE for the names of municipalities + * ("Mogi das Cruzes", "Santa Bárbara d'Oeste"). The elided `d` of "d'Oeste" is not a member of + * this list: on its own it is a designator ("Rua D", "Quadra D"), so `capitalize` lower-cases it + * structurally, only when an apostrophe and a word follow it. Applying it to personal and institutional names + * ("Ministério da Justiça", "José da Silva") is this library's extension of that rule; the + * Manual does not spell those two cases out. + * + * @see Official: https://www4.planalto.gov.br/centrodeestudos/assuntos/manual-de-redacao-da-presidencia-da-republica/manual-de-redacao.pdf + * Manual de Redação da Presidência da República, 3ª edição (Portaria nº 1.369, de 27/12/2018), + * items 5.1.8 b) and 10.2 a). The landing page below only recounts the editions and links to + * this PDF; the rule itself is in the PDF. + * @see Official: https://www4.planalto.gov.br/centrodeestudos/assuntos/manual-de-redacao-da-presidencia-da-republica + * The Presidência page that publishes it ("Acesse aqui a íntegra da última edição publicada"). + * @see Official: https://servicodados.ibge.gov.br/api/docs/localidades + */ export const PREPOSITIONS = [ "a", "com", @@ -6,6 +29,12 @@ export const PREPOSITIONS = [ "de", "do", "dos", + "del", + "della", + "den", + "der", + "di", + "du", "e", "em", "na", @@ -15,8 +44,170 @@ export const PREPOSITIONS = [ "o", "por", "sem", + "van", + "von", +]; + +/** + * Company designations and document abbreviations that are written in upper case in Brazilian + * names. "SA" without punctuation is deliberately absent: it is indistinguishable from the + * surname "Sá" typed without its accent, which would turn "Jose de Sa" into "Jose de SA". + * + * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l6404consol.htm + * Lei nº 6.404/1976, art. 3º: the sociedade anônima is designated by "companhia" or "sociedade + * anônima", "expressas por extenso ou abreviadamente", the abbreviations being CIA, S.A. and S/A. + * @see Official: https://www.planalto.gov.br/ccivil_03/leis/2002/l10406compilada.htm + * Código Civil, art. 1.158: the sociedade limitada carries the final word "limitada" "ou a sua + * abreviatura" (LTDA); art. 991 defines the sociedade em conta de participação (SCP), enrolled in + * the CNPJ under that abbreviation; art. 980-A, which created the EIRELI, was revoked by the Lei + * nº 14.382/2022, so the abbreviation is kept only because registered names still carry it. + * @see Official: https://www.planalto.gov.br/ccivil_03/leis/lcp/lcp123.htm + * Lei Complementar nº 123/2006, art. 72, revoked by the Lei Complementar nº 155/2016, added + * "Microempresa ou Empresa de Pequeno Porte, ou suas respectivas abreviações, ME ou EPP" to the + * name; art. 18-A defines the Microempreendedor Individual (MEI). + * @see Official: https://www.gov.br/empresas-e-negocios/pt-br/drei/legislacao/instrucoes-normativas/arquivos-instrucoes-normativas-em-vigor/anexo-iv-limitada_link.pdf + * IN DREI nº 81/2020, Anexo IV (Manual de Registro de Sociedade Limitada), the rules the Juntas + * Comerciais follow for the nome empresarial and for the "conversão de sociedade simples ou + * associação do cartório de registro de pessoas jurídicas para a Junta Comercial". The S/S + * abbreviation itself is registry practice: no DREI norm spells it out, and it is kept in this + * list only because registered names carry it. + * @see Official: https://www.gov.br/empresas-e-negocios/pt-br/drei/legislacao/instrucoes-normativas + * The DREI index of instruções normativas in force, where that Anexo is published. + */ +const BUSINESS_ABBREVIATIONS = [ + "CEP", + "CIA", + "CNPJ", + "CPF", + "EIRELI", + "EPP", + "LTDA", + "ME", + "MEI", + "RG", + "S.A.", + "S.S.", + "S/A", + "S/S", + "SCP", + "UF", +]; + +/** + * Roman numerals that appear inside Brazilian names and addresses ("João Paulo II", "Rua XV de + * Novembro", "Avenida Papa João XXIII"). The single letter numerals (V, X, L, C, D, M) are left + * out because a single letter is already written in upper case by the default rule, and VI is + * left out because it collides with the pt-BR verb form "vi". + */ +const ROMAN_NUMERALS = [ + "II", + "III", + "IV", + "VII", + "VIII", + "IX", + "XI", + "XII", + "XIII", + "XIV", + "XV", + "XVI", + "XVII", + "XVIII", + "XIX", + "XX", + "XXI", + "XXII", + "XXIII", +]; + +/** Words that are written in upper case wherever they appear, the default `upperCaseWords`. */ +export const UPPER_CASE_WORDS = [...BUSINESS_ABBREVIATIONS, ...ROMAN_NUMERALS]; + +/** + * The two letter code of each Brazilian state, written in upper case when it follows a `/` + * ("Santana/RS"), the way a municipality and its Federative Unit are written together. The codes + * are a literal copy of the `code` of every state published by the IBGE (see + * `_internals/constants/states`, whose table is not imported here so that `capitalize` does not + * carry the whole state dataset into a consumer's bundle); `capitalize.test.ts` asserts that this + * list is exactly that one. + * + * @see Official: https://servicodados.ibge.gov.br/api/docs/localidades + */ +export const STATE_CODES: StateCode[] = [ + "AC", + "AL", + "AP", + "AM", + "BA", + "CE", + "DF", + "ES", + "GO", + "MA", + "MT", + "MS", + "MG", + "PA", + "PB", + "PR", + "PE", + "PI", + "RJ", + "RN", + "RS", + "RO", + "RR", + "SC", + "SP", + "SE", + "TO", ]; -export const SEPARATOR_REGEX = /(\s+|[-/])/; +/** + * Word boundaries: runs of whitespace, hyphen and slash (kept in place), the apostrophe of + * `d'Oeste`, and the punctuation that may wrap or follow a word without a space, so `(empresa)` + * and `bairro:centro` still capitalize the word after the mark. + */ +export const SEPARATOR_REGEX = /(\s+|[-/'’‘(){}[\]"“”:;,])/; + +/** A single separator token that is kept where it is, as opposed to a whitespace run. */ +export const PUNCTUATION_REGEX = /^[-/'’‘(){}[\]"“”:;,]$/; export const WHITESPACE_REGEX = /^\s+$/; + +/** + * A token that carries a word: one that holds at least one character that is not a separator. The + * empty token that `String.prototype.split` leaves between two separators does not, and neither + * does a whitespace run or a single punctuation mark. + */ +export const WORD_REGEX = /[^\s/'’‘(){}[\]"“”:;,-]/; + +/** + * The separators that join two words into one name ("Rio-de-Janeiro", "Santa Bárbara d'Oeste", + * "Porto Alegre/RS"), as opposed to the punctuation that closes a phrase (`,`, `;`, `:`, brackets + * and quotes). A word of the lower case list is only written in lower case when another word + * follows it across separators of this kind; before a closing mark, or at the end of the value, it + * is a designator ("Rua D", "Quadra A, Lote B") and keeps its capital. + */ +export const JOINER_REGEX = /^(?:\s+|[-/'’‘])$/; + +/** The apostrophe that elides the particle of `d'Oeste` and marks the English possessive of `Bob's`. */ +export const APOSTROPHE_REGEX = /^['’‘]$/; + +/** The elided particle of `Santa Bárbara d'Oeste`, lower case only when an apostrophe and a word follow it. */ +export const ELIDED_PARTICLE = "d"; + +/** + * Designations that are only written in upper case in the designation position, that is, as the + * last word of the name ("Fulano Comércio ME") or right before another designation + * ("Fulano ME EPP"). `ME` is also the pt-BR pronoun "me", so upper-casing it wherever it appears + * turned free text into `"Diga-ME a Verdade"` and the municipality of Não-Me-Toque/RS into + * `"Não-ME-Toque"`; anywhere else in the value it is written as an ordinary word. + * + * @see Official: https://www.planalto.gov.br/ccivil_03/leis/lcp/lcp123.htm + * Lei Complementar nº 123/2006, art. 72 (revoked by the Lei Complementar nº 155/2016): the + * abbreviation is added "ao final" of the firma or denominação, which is the position this list + * keeps it in. + */ +export const TRAILING_DESIGNATIONS = ["ME"]; diff --git a/src/convert-currency-to-words/convert-currency-to-words.test.ts b/src/convert-currency-to-words/convert-currency-to-words.test.ts index cbd4ec87..91ad2562 100644 --- a/src/convert-currency-to-words/convert-currency-to-words.test.ts +++ b/src/convert-currency-to-words/convert-currency-to-words.test.ts @@ -1,14 +1,8 @@ import * as fc from "fast-check"; -import { - NUMBER_TO_WORDS_MAX_VALUE, - type WordsCase, -} from "../_internals/number-to-words/number-to-words"; +import { NUMBER_TO_WORDS_MAX_VALUE } from "../_internals/number-to-words/number-to-words"; import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; -import { - convertCurrencyToWords, - type ConvertCurrencyToWordsOptions, -} from "./convert-currency-to-words"; +import { convertCurrencyToWords } from "./convert-currency-to-words"; function expectAmounts(cases: readonly (readonly [number, string])[]): void { const failures = cases @@ -45,12 +39,12 @@ describe("convertCurrencyToWords", () => { test("should join reais and centavos with 'e' (1523.45, brutils 'convert_real_to_text' example)", () => { expect(convertCurrencyToWords(1523.45)).toBe( - "mil, quinhentos e vinte e três reais e quarenta e cinco centavos", + "mil quinhentos e vinte e três reais e quarenta e cinco centavos", ); }); test("should not insert 'de' when a mil/hundred group follows the million group", () => { - expect(convertCurrencyToWords(1_000_230)).toBe("um milhão, duzentos e trinta reais"); + expect(convertCurrencyToWords(1_000_230)).toBe("um milhão duzentos e trinta reais"); }); test("should return only the centavos when the reais part is zero", () => { @@ -118,8 +112,10 @@ describe("convertCurrencyToWords", () => { expect(convertCurrencyToWords(9_007_199_254_740.99)).toContain("noventa e nove centavos"); }); - test("should still report cents exactly at the Number.MAX_SAFE_INTEGER cents boundary", () => { - expect(convertCurrencyToWords(90_071_992_547_409.9)).toContain("noventa e um centavos"); + test("should still report cents exactly at the Number.MAX_SAFE_INTEGER cents boundary, reading the 90 cents the double holds (90071992547409.9 is exactly 90071992547409.90625, the 91st cent only shows up when 9007199254740990.625 is scaled and rounded to Number.MAX_SAFE_INTEGER)", () => { + expect(convertCurrencyToWords(90_071_992_547_409.9)).toBe( + "noventa trilhões setenta e um bilhões novecentos e noventa e dois milhões quinhentos e quarenta e sete mil quatrocentos e nove reais e noventa centavos", + ); }); }); @@ -138,35 +134,78 @@ describe("convertCurrencyToWords", () => { test("should absorb the noise of an amount that is itself the sum of two floats", () => { expect(convertCurrencyToWords(0.1 + 0.2)).toBe("trinta centavos"); }); - }); - describe("case option", () => { - test("should keep the result lowercase by default", () => { - expect(convertCurrencyToWords(1000)).toBe("mil reais"); + test("should not invent a cent for a large amount whose sub cent part scales to a hair below the next integer (1000000000000.0099 * 100 is 100000000000000.98)", () => { + expect(convertCurrencyToWords(1_000_000_000_000.0099)).toBe("um trilhão de reais"); }); - test("should keep the result lowercase for 'lower'", () => { - expect(convertCurrencyToWords(1000, { case: "lower" })).toBe("mil reais"); + test("should truncate, not round, the sub cent part of a large amount", () => { + const cases: [number, string][] = [ + [1_000_000_000_000.0199, "um trilhão de reais e um centavo"], + [ + 123_456_789_012.345, + "cento e vinte e três bilhões quatrocentos e cinquenta e seis milhões setecentos e oitenta e nove mil e doze reais e trinta e quatro centavos", + ], + [ + 87_654_321_098.7654, + "oitenta e sete bilhões seiscentos e cinquenta e quatro milhões trezentos e vinte e um mil e noventa e oito reais e setenta e seis centavos", + ], + [ + 999_999_999_999.999, + "novecentos e noventa e nove bilhões novecentos e noventa e nove milhões novecentos e noventa e nove mil novecentos e noventa e nove reais e noventa e nove centavos", + ], + [ + 9_007_199_254_740.99, + "nove trilhões sete bilhões cento e noventa e nove milhões duzentos e cinquenta e quatro mil setecentos e quarenta reais e noventa e nove centavos", + ], + ]; + + expectAmounts(cases); }); - test("should capitalize only the first letter for 'sentence'", () => { - expect(convertCurrencyToWords(1000, { case: "sentence" })).toBe("Mil reais"); - expect(convertCurrencyToWords(0, { case: "sentence" })).toBe("Zero reais"); + test("should return 'zero reais' for an amount so small that it is written in exponent notation", () => { + expect(convertCurrencyToWords(1.5e-7)).toBe("zero reais"); + expect(convertCurrencyToWords(-1.5e-7)).toBe("zero reais"); + expect(convertCurrencyToWords(1e-7)).toBe("zero reais"); + expect(convertCurrencyToWords(Number.MIN_VALUE)).toBe("zero reais"); }); - test("should uppercase everything for 'upper', keeping accents", () => { - expect(convertCurrencyToWords(1000, { case: "upper" })).toBe("MIL REAIS"); - expect(convertCurrencyToWords(1523.45, { case: "upper" })).toBe( - "MIL, QUINHENTOS E VINTE E TRÊS REAIS E QUARENTA E CINCO CENTAVOS", + test("should read back the exact cents of every amount from R$ 0.00 to R$ 1 000.00, cent by cent, and of every 997th cent up to R$ 20 000.00, against the same amount built from whole reais and whole cents", () => { + const reaisWords = Array.from({ length: 20_001 }, (_, reais) => + convertCurrencyToWords(reais), ); - expect(convertCurrencyToWords(-5.5, { case: "upper" })).toBe( - "MENOS CINCO REAIS E CINQUENTA CENTAVOS", + const centavosWords = Array.from({ length: 100 }, (_, centavos) => + convertCurrencyToWords(centavos / 100), ); + const expectedWords = (reais: number, centavos: number): string => { + if (reais === 0) return centavosWords[centavos]; + if (centavos === 0) return reaisWords[reais]; + + return `${reaisWords[reais]} e ${centavosWords[centavos]}`; + }; + const failures: number[] = []; + + const check = (cents: number): void => { + const expected = expectedWords(Math.floor(cents / 100), cents % 100); + + if (convertCurrencyToWords(cents / 100) !== expected) failures.push(cents); + }; + + for (let cents = 0; cents <= 100_000; cents++) check(cents); + for (let cents = 100_997; cents <= 2_000_000; cents += 997) check(cents); + + expect(failures).toEqual([]); }); + }); - test("should ignore an invalid case value and fall back to 'lower'", () => { - // @ts-expect-error: intentionally invalid input - expect(convertCurrencyToWords(1000, { case: "invalid" })).toBe("mil reais"); + describe("letter case", () => { + test("should always keep the result lowercase", () => { + expect(convertCurrencyToWords(1000)).toBe("mil reais"); + expect(convertCurrencyToWords(0)).toBe("zero reais"); + expect(convertCurrencyToWords(-5.5)).toBe("menos cinco reais e cinquenta centavos"); + expect(convertCurrencyToWords(1523.45)).toBe( + "mil quinhentos e vinte e três reais e quarenta e cinco centavos", + ); }); }); @@ -187,7 +226,7 @@ describe("convertCurrencyToWords", () => { [11, "onze centavos"], [12, "doze centavos"], [13, "treze centavos"], - [14, "catorze centavos"], + [14, "quatorze centavos"], [15, "quinze centavos"], [16, "dezesseis centavos"], [17, "dezessete centavos"], @@ -287,7 +326,7 @@ describe("convertCurrencyToWords", () => { [111, "um real e onze centavos"], [112, "um real e doze centavos"], [113, "um real e treze centavos"], - [114, "um real e catorze centavos"], + [114, "um real e quatorze centavos"], [115, "um real e quinze centavos"], [116, "um real e dezesseis centavos"], [117, "um real e dezessete centavos"], @@ -332,22 +371,22 @@ describe("convertCurrencyToWords", () => { const cases: [number, string][] = [ [1000, "mil reais"], [1000.01, "mil reais e um centavo"], - [1101, "mil, cento e um reais"], - [1101.01, "mil, cento e um reais e um centavo"], - [1523.45, "mil, quinhentos e vinte e três reais e quarenta e cinco centavos"], + [1101, "mil cento e um reais"], + [1101.01, "mil cento e um reais e um centavo"], + [1523.45, "mil quinhentos e vinte e três reais e quarenta e cinco centavos"], [1_000_000, "um milhão de reais"], [1_000_000.01, "um milhão de reais e um centavo"], [2_000_000, "dois milhões de reais"], [1_000_001, "um milhão e um reais"], [ 999_999_999_999_999, - "novecentos e noventa e nove trilhões, novecentos e noventa e nove bilhões, novecentos e noventa e nove milhões, novecentos e noventa e nove mil, novecentos e noventa e nove reais", + "novecentos e noventa e nove trilhões novecentos e noventa e nove bilhões novecentos e noventa e nove milhões novecentos e noventa e nove mil novecentos e noventa e nove reais", ], [1.999, "um real e noventa e nove centavos"], [100.5, "cem reais e cinquenta centavos"], [2, "dois reais"], [10.5, "dez reais e cinquenta centavos"], - [999_999, "novecentos e noventa e nove mil, novecentos e noventa e nove reais"], + [999_999, "novecentos e noventa e nove mil novecentos e noventa e nove reais"], [100, "cem reais"], [1_000_000_000, "um bilhão de reais"], [2_000_000_000, "dois bilhões de reais"], @@ -364,7 +403,7 @@ describe("convertCurrencyToWords", () => { [0.5, "cinquenta centavos"], [1, "um real"], [-50.25, "menos cinquenta reais e vinte e cinco centavos"], - [1523.45, "mil, quinhentos e vinte e três reais e quarenta e cinco centavos"], + [1523.45, "mil quinhentos e vinte e três reais e quarenta e cinco centavos"], [1_000_000, "um milhão de reais"], [2_000_000, "dois milhões de reais"], [1_000_000_000, "um bilhão de reais"], @@ -375,7 +414,7 @@ describe("convertCurrencyToWords", () => { [2_000_000_000.99, "dois bilhões de reais e noventa e nove centavos"], [ 1_234_567_890.5, - "um bilhão, duzentos e trinta e quatro milhões, quinhentos e sessenta e sete mil, oitocentos e noventa reais e cinquenta centavos", + "um bilhão duzentos e trinta e quatro milhões quinhentos e sessenta e sete mil oitocentos e noventa reais e cinquenta centavos", ], [0.001, "zero reais"], [0.009, "zero reais"], @@ -385,13 +424,13 @@ describe("convertCurrencyToWords", () => { [1_000_000_000.99, "um bilhão de reais e noventa e nove centavos"], [ 999_999_999_999.99, - "novecentos e noventa e nove bilhões, novecentos e noventa e nove milhões, novecentos e noventa e nove mil, novecentos e noventa e nove reais e noventa e nove centavos", + "novecentos e noventa e nove bilhões novecentos e noventa e nove milhões novecentos e noventa e nove mil novecentos e noventa e nove reais e noventa e nove centavos", ], [1_000_000_000_000.01, "um trilhão de reais e um centavo"], [1_000_000_000_000.99, "um trilhão de reais e noventa e nove centavos"], [ 9_999_999_999_999.99, - "nove trilhões, novecentos e noventa e nove bilhões, novecentos e noventa e nove milhões, novecentos e noventa e nove mil, novecentos e noventa e nove reais e noventa e nove centavos", + "nove trilhões novecentos e noventa e nove bilhões novecentos e noventa e nove milhões novecentos e noventa e nove mil novecentos e noventa e nove reais e noventa e nove centavos", ], ]; expectAmounts(cases); @@ -431,15 +470,12 @@ describe("convertCurrencyToWords", () => { ); }); - test("should uppercase the result the same way as the lower case result, for the 'upper' case option", () => { + test("should never return a character in upper case", () => { fc.assert( fc.property(safeCentsArbitrary, (cents) => { - const value = cents / 100; - const lower = convertCurrencyToWords(value); + const words = convertCurrencyToWords(cents / 100); - expect(convertCurrencyToWords(value, { case: "upper" })).toBe( - lower.toLocaleUpperCase("pt-BR"), - ); + expect(words).toBe(words.toLocaleLowerCase("pt-BR")); }), ); }); @@ -447,12 +483,9 @@ describe("convertCurrencyToWords", () => { }); describe("convertCurrencyToWords types", () => { - test("should take a number, options, and return a string", () => { + test("should take a single number and return a string", () => { expectTypeOf(convertCurrencyToWords).parameter(0).toEqualTypeOf(); - expectTypeOf(convertCurrencyToWords) - .parameter(1) - .toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); + expectTypeOf(convertCurrencyToWords).parameters.toEqualTypeOf<[value: number]>(); expectTypeOf(convertCurrencyToWords).returns.toEqualTypeOf(); }); }); diff --git a/src/convert-currency-to-words/convert-currency-to-words.ts b/src/convert-currency-to-words/convert-currency-to-words.ts index c1d0a35e..153ec7b2 100644 --- a/src/convert-currency-to-words/convert-currency-to-words.ts +++ b/src/convert-currency-to-words/convert-currency-to-words.ts @@ -1,35 +1,35 @@ -import { applyWordsCase } from "../_internals/apply-words-case/apply-words-case"; import { NUMBER_TO_WORDS_MAX_VALUE, numberToWords, - type WordsCase, } from "../_internals/number-to-words/number-to-words"; -/** Options of `convertCurrencyToWords`. */ -export type ConvertCurrencyToWordsOptions = { - /** Letter case applied to the result: `"lower"` (unchanged), `"sentence"` (capitalizes only the first letter) or `"upper"` (uppercases everything, keeping accents). Defaults to `"lower"`; an invalid value is ignored and `"lower"` is used instead. */ - case?: WordsCase; -}; - const MILLION_SCALE_SUFFIXES = ["lhão", "lhões"]; +const ONE_CENTAVO = 0.01; + /** * Scales an amount to whole cents, truncating it, without letting the floating point noise of - * the multiplication decide the result. `absolute * 100` lands a hair off the integer it - * should be (`1.15 * 100` is `114.99999999999999`, `0.57 * 100` is `56.99999999999999`), so a - * scaled value within one double rounding error of an integer is read as that integer. - * An amount that is genuinely below the next cent sits much further away than that - * (`1.999999999 * 100` is `199.9999999`) and is truncated, as it must be. + * the multiplication decide the result. `absolute * 100` lands a hair off the integer it should + * be (`1.15 * 100` is `114.99999999999999`, `0.57 * 100` is `56.99999999999999`) and that error + * grows with the amount, up to a whole cent for the trillions (`1000000000000.0099 * 100` is + * `100000000000000.98`, a hair below an integer while the amount holds no cents at all), so the + * cents are read off the decimal notation of the amount instead of off the product. + * `String(absolute)` is the shortest decimal that reads back as `absolute`, i.e. the amount as + * it was written, and its first two fractional digits are the cents; anything after them is + * truncated, as it must be (`1.999999999` is one real and 99 cents). + * An amount below one cent has no cents to read, which also keeps `String(absolute)` in plain + * decimal notation: the exponent form only shows up below `1e-6` and from `1e21` up, and an + * amount that large is out of range for the caller. * * @param {number} absolute - The absolute amount in reais. * @returns {number} The amount truncated to whole cents. */ const toCents = (absolute: number): number => { - const scaled = absolute * 100; - const rounded = Math.round(scaled); + if (absolute < ONE_CENTAVO) return 0; + + const [wholeReais, fraction = ""] = String(absolute).split("."); - // Stryker disable next-line EqualityOperator: `<` is equivalent, the two sides are never equal. Writing scaled as m * 2 ** (k - 52) with 2 ** k <= scaled < 2 ** (k + 1) and m its 53 bit significand, both scaled and rounded are multiples of the ulp 2 ** (k - 52), so the difference is j * 2 ** (k - 52) for an integer j, while Number.EPSILON * scaled is exactly m * 2 ** (k - 104): equality asks for m === j * 2 ** 52, and m < 2 ** 53 leaves only m === 2 ** 52, i.e. scaled a power of two. A power of two of at least 1 is an integer, whose difference is 0, and one below 1 rounds to 0 or to 1 at a distance of at least 0.25, never one ulp. The only case where both sides are 0 is scaled === 0, where rounded and Math.trunc(scaled) are both 0 anyway - return Math.abs(scaled - rounded) <= Number.EPSILON * scaled ? rounded : Math.trunc(scaled); + return Number(`${wholeReais}${fraction.slice(0, 2).padEnd(2, "0")}`); }; const endsInMillionScale = (words: string): boolean => @@ -38,7 +38,7 @@ const endsInMillionScale = (words: string): boolean => /** * Formats a monetary amount in Brazilian Reais as its "por extenso" textual representation, * the style used to write out the amount by hand on cheques and contracts, e.g. `1523.45` - * becomes `"mil, quinhentos e vinte e três reais e quarenta e cinco centavos"`. + * becomes `"mil quinhentos e vinte e três reais e quarenta e cinco centavos"`. * * `value` is truncated (not rounded) to 2 decimal places before conversion, matching * `brutils`' `convert_real_to_text`. The singular noun is used for exactly 1 ("um real", @@ -51,28 +51,25 @@ const endsInMillionScale = (words: string): boolean => * double cannot carry cents at all, so the amount is read as a whole number of reais instead of * reporting cents that the input never held. * + * The result is always lowercase; apply any other casing to it yourself. + * * @param {number} value - The monetary amount to convert, in reais (e.g. `1523.45` for R$ 1.523,45). - * @param {ConvertCurrencyToWordsOptions} [options] - Optional formatting options. - * @param {WordsCase} [options.case] - Letter case applied to the result. Defaults to `"lower"`. * @returns {string} The amount written out in Portuguese, or `""` for invalid input. * * @example * ```typescript - * convertCurrencyToWords(1523.45); // "mil, quinhentos e vinte e três reais e quarenta e cinco centavos" + * convertCurrencyToWords(1523.45); // "mil quinhentos e vinte e três reais e quarenta e cinco centavos" * convertCurrencyToWords(1); // "um real" * convertCurrencyToWords(0.01); // "um centavo" * convertCurrencyToWords(1000000); // "um milhão de reais" * convertCurrencyToWords(0); // "zero reais" * convertCurrencyToWords(-5.5); // "menos cinco reais e cinquenta centavos" - * convertCurrencyToWords(1000, { case: "upper" }); // "MIL REAIS" + * convertCurrencyToWords(-0.001); // "zero reais" * ``` * * @see Based on: https://github.com/brazilian-utils/python/blob/main/brutils/currency.py */ -export const convertCurrencyToWords = ( - value: number, - options?: ConvertCurrencyToWordsOptions, -): string => { +export const convertCurrencyToWords = (value: number): string => { if (!Number.isFinite(value)) return ""; const absolute = Math.abs(value); @@ -97,11 +94,10 @@ export const convertCurrencyToWords = ( parts.push(reais > 0 ? `e ${centavosText}` : centavosText); } - if (reais === 0 && centavos === 0) return applyWordsCase("zero reais", options?.case); + if (reais === 0 && centavos === 0) return "zero reais"; const joined = parts.join(" "); - // Stryker disable next-line EqualityOperator: equivalent, value is never exactly 0 here (reais === 0 && centavos === 0 already returned above) - const result = value < 0 ? `menos ${joined}` : joined; - return applyWordsCase(result, options?.case); + // Stryker disable next-line EqualityOperator: equivalent, value is never exactly 0 here (reais === 0 && centavos === 0 already returned above) + return value < 0 ? `menos ${joined}` : joined; }; diff --git a/src/convert-date-to-words/convert-date-to-words.test.ts b/src/convert-date-to-words/convert-date-to-words.test.ts index 63d6e816..9b1f08f4 100644 --- a/src/convert-date-to-words/convert-date-to-words.test.ts +++ b/src/convert-date-to-words/convert-date-to-words.test.ts @@ -1,7 +1,6 @@ import * as fc from "fast-check"; import { MONTH_NAMES, WEEKDAY_NAMES } from "../_internals/constants/number-words"; -import { type WordsCase } from "../_internals/number-to-words/number-to-words"; import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; import { convertDateToWords, type ConvertDateToWordsOptions } from "./convert-date-to-words"; @@ -73,41 +72,19 @@ describe("convertDateToWords", () => { expect(convertDateToWords("32/01/2024")).toBe(""); }); - describe("case option", () => { - test("should keep the result lowercase by default", () => { + describe("letter case", () => { + test("should always keep the result lowercase", () => { expect(convertDateToWords("01/01/2024")).toBe( "primeiro de janeiro de dois mil e vinte e quatro", ); - }); - - test("should keep the result lowercase for 'lower'", () => { - expect(convertDateToWords("01/01/2024", { case: "lower" })).toBe( - "primeiro de janeiro de dois mil e vinte e quatro", - ); - }); - - test("should capitalize only the first letter for 'sentence'", () => { - expect(convertDateToWords("01/01/2024", { case: "sentence" })).toBe( - "Primeiro de janeiro de dois mil e vinte e quatro", - ); - expect(convertDateToWords("10/05/1999", { case: "sentence" })).toBe( - "Dez de maio de mil novecentos e noventa e nove", - ); - }); - - test("should uppercase everything for 'upper', keeping accents", () => { - expect(convertDateToWords("02/03/2024", { case: "upper" })).toBe( - "DOIS DE MARÇO DE DOIS MIL E VINTE E QUATRO", + expect(convertDateToWords("02/03/2024")).toBe("dois de março de dois mil e vinte e quatro"); + expect(convertDateToWords("02/03/2024", { weekday: true })).toBe( + "sábado, dois de março de dois mil e vinte e quatro", ); }); + }); - test("should ignore an invalid case value and fall back to 'lower'", () => { - expect( - // @ts-expect-error: intentionally invalid input - convertDateToWords("01/01/2024", { case: "invalid" }), - ).toBe("primeiro de janeiro de dois mil e vinte e quatro"); - }); - + describe("style option", () => { test("should write only the month name and leave day/year as digits for 'month'", () => { expect(convertDateToWords("02/03/2024", { style: "month" })).toBe("2 de março de 2024"); }); @@ -203,15 +180,6 @@ describe("convertDateToWords", () => { "segunda-feira, 1º de janeiro de 2024", ); }); - - test("should combine with the 'case' option", () => { - expect(convertDateToWords("02/03/2024", { weekday: true, case: "sentence" })).toBe( - "Sábado, dois de março de dois mil e vinte e quatro", - ); - expect(convertDateToWords("02/03/2024", { weekday: true, case: "upper" })).toBe( - "SÁBADO, DOIS DE MARÇO DE DOIS MIL E VINTE E QUATRO", - ); - }); }); describe("invalid input", () => { @@ -359,7 +327,7 @@ describe("convertDateToWords", () => { ["11/03/2024", "onze de março de dois mil e vinte e quatro"], ["12/03/2024", "doze de março de dois mil e vinte e quatro"], ["13/03/2024", "treze de março de dois mil e vinte e quatro"], - ["14/03/2024", "catorze de março de dois mil e vinte e quatro"], + ["14/03/2024", "quatorze de março de dois mil e vinte e quatro"], ["15/03/2024", "quinze de março de dois mil e vinte e quatro"], ["16/03/2024", "dezesseis de março de dois mil e vinte e quatro"], ["17/03/2024", "dezessete de março de dois mil e vinte e quatro"], @@ -381,7 +349,7 @@ describe("convertDateToWords", () => { expectDates(cases); }); - test("should reproduce every published brutils 'convert_date_to_text' example (tests/test_date_utils.py, lowercase here because brutils always capitalizes and this library exposes that as case: 'sentence')", () => { + test("should reproduce every published brutils 'convert_date_to_text' example (tests/test_date_utils.py, lowercase here because brutils always capitalizes and this library leaves casing to the caller)", () => { const cases: [string, string][] = [ ["15/08/2024", "quinze de agosto de dois mil e vinte e quatro"], ["01/01/2000", "primeiro de janeiro de dois mil"], @@ -467,7 +435,6 @@ describe("convertDateToWords types", () => { expectTypeOf(convertDateToWords) .parameter(1) .toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf< "full" | "month" | undefined >(); diff --git a/src/convert-date-to-words/convert-date-to-words.ts b/src/convert-date-to-words/convert-date-to-words.ts index d73b74df..a908c96c 100644 --- a/src/convert-date-to-words/convert-date-to-words.ts +++ b/src/convert-date-to-words/convert-date-to-words.ts @@ -1,11 +1,8 @@ -import { applyWordsCase } from "../_internals/apply-words-case/apply-words-case"; import { MONTH_NAMES, WEEKDAY_NAMES } from "../_internals/constants/number-words"; -import { numberToWords, type WordsCase } from "../_internals/number-to-words/number-to-words"; +import { numberToWords } from "../_internals/number-to-words/number-to-words"; /** Options of `convertDateToWords`. */ export type ConvertDateToWordsOptions = { - /** Letter case applied to the result: `"lower"` (unchanged), `"sentence"` (capitalizes only the first letter) or `"upper"` (uppercases everything, keeping accents). Defaults to `"lower"`; an invalid value is ignored and `"lower"` is used instead. */ - case?: WordsCase; /** Output style: `"full"` spells out the day, month and year (`"dois de março de dois mil e vinte e quatro"`); `"month"` spells out only the month name and leaves the day and year as digits (`"2 de março de 2024"`, day 1 as `"1º"`). Defaults to `"full"`; an invalid value is ignored and `"full"` is used instead. */ style?: "full" | "month"; /** Prefixes the pt-BR weekday name (lowercase) followed by a comma, e.g. `"sábado, dois de março de dois mil e vinte e quatro"`. The weekday is derived from the resolved calendar date (the `Date`'s local calendar date, or the parsed civil date for a string). Defaults to `false`. */ @@ -44,18 +41,17 @@ const dayToWords = (day: number, monthStyle: boolean): string => { * with no timezone conversion. With the default `"full"` `options.style`, day 1 is written as * "primeiro" and every other day uses the cardinal number; with `"month"`, only the month name * is spelled out and the day/year are written as digits (day 1 as `"1º"`). Month names are - * lowercase. In `"full"` style the year is written out as a cardinal number without the - * thousands comma that `convertNumberToWords`/`convertCurrencyToWords` use (`1999` reads as - * `"mil novecentos e noventa e nove"`, not `"mil, novecentos e noventa e nove"`), matching how a + * lowercase. In `"full"` style the year is written out as a cardinal number the way + * `convertNumberToWords` writes it (`1999` reads as `"mil novecentos e noventa e nove"`), matching how a * date is read aloud. `options.weekday` prefixes the pt-BR weekday name (lowercase) followed by - * a comma. February 29th is accepted on the leap years of the proleptic Gregorian calendar + * a comma. The result is always lowercase; apply any other casing to it yourself. + * February 29th is accepted on the leap years of the proleptic Gregorian calendar * (divisible by 4, except centuries that are not divisible by 400). Returns `""` when `value` is * not one of those forms, is an invalid `Date`, names a day/month that does not exist (e.g. * `"31/04/2024"` or `"29/02/2023"`), or falls before year 1, which has no year to write out. * * @param {Date|string} value - The date to convert: a `Date`, `"dd/mm/yyyy"` or ISO `"yyyy-mm-dd"`. * @param {ConvertDateToWordsOptions} [options] - Optional formatting options. - * @param {WordsCase} [options.case] - Letter case applied to the result. Defaults to `"lower"`. * @param {"full"|"month"} [options.style] - Output style. Defaults to `"full"`. * @param {boolean} [options.weekday] - Prefixes the pt-BR weekday name and a comma. Defaults to `false`. * @returns {string} The date written out in Portuguese, or `""` for invalid input. @@ -65,7 +61,6 @@ const dayToWords = (day: number, monthStyle: boolean): string => { * convertDateToWords("01/01/2024"); // "primeiro de janeiro de dois mil e vinte e quatro" * convertDateToWords("2024-01-02"); // "dois de janeiro de dois mil e vinte e quatro" * convertDateToWords(new Date(2024, 0, 1)); // "primeiro de janeiro de dois mil e vinte e quatro" - * convertDateToWords("01/01/2024", { case: "sentence" }); // "Primeiro de janeiro de dois mil e vinte e quatro" * convertDateToWords("02/03/2024", { style: "month" }); // "2 de março de 2024" * convertDateToWords("01/01/2024", { style: "month" }); // "1º de janeiro de 2024" * convertDateToWords("02/03/2024", { weekday: true }); // "sábado, dois de março de dois mil e vinte e quatro" @@ -116,13 +111,10 @@ export const convertDateToWords = ( const monthName = MONTH_NAMES[month - 1]; const isMonthStyle = options?.style === "month"; - const yearWords = isMonthStyle ? String(year) : numberToWords(year).replaceAll(", ", " "); + const yearWords = isMonthStyle ? String(year) : numberToWords(year); const dateWords = `${dayToWords(day, isMonthStyle)} de ${monthName} de ${yearWords}`; - const result = - options?.weekday === true - ? `${WEEKDAY_NAMES[getWeekdayIndex(year, month, day)]}, ${dateWords}` - : dateWords; - - return applyWordsCase(result, options?.case); + return options?.weekday === true + ? `${WEEKDAY_NAMES[getWeekdayIndex(year, month, day)]}, ${dateWords}` + : dateWords; }; diff --git a/src/convert-license-plate-to-mercosul/constants.ts b/src/convert-license-plate-to-mercosul/constants.ts index edb2c799..adb088ea 100644 --- a/src/convert-license-plate-to-mercosul/constants.ts +++ b/src/convert-license-plate-to-mercosul/constants.ts @@ -1,12 +1,16 @@ /** - * Official digit to letter conversion table used to turn an old format plate's 5th character - * into the Mercosul format's embedded letter (0=A, 1=B, ..., 9=J). + * Digit to letter conversion table used to turn an old format plate's 5th character into the + * Mercosul format's embedded letter (0=A, 1=B, ..., 9=J). * - * Resolução CONTRAN nº 969/2022, art. 2º § 4º. The linked DOU PDF has no annexes; the table - * above comes from Anexo II, published separately on the CONTRAN resolutions page. + * Resolução CONTRAN nº 969/2022, art. 2º § 4º, is what requires the substitution, "conforme + * padrão previsto no Anexo II". The table itself is that Anexo II, which calls it a "tabela + * equiparativa, para substituição do antepenúltimo caractere, de número para letra". Its range + * of letters is deliberately limited to `A` through `J`, "apenas para a conversão da PNU para o + * novo sistema de PIV". The annexes are published in a PDF of their own, separate from the + * resolution's text; both are cited below. * * @see Official: https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes/resolucao9692022.pdf - * @see Official: https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes + * @see Official: https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes/resolucao9692022anexos.pdf */ export const DIGIT_TO_MERCOSUL_LETTER: Record = { "0": "A", diff --git a/src/convert-license-plate-to-mercosul/convert-license-plate-to-mercosul.test.ts b/src/convert-license-plate-to-mercosul/convert-license-plate-to-mercosul.test.ts index f2dd52ba..5fb76116 100644 --- a/src/convert-license-plate-to-mercosul/convert-license-plate-to-mercosul.test.ts +++ b/src/convert-license-plate-to-mercosul/convert-license-plate-to-mercosul.test.ts @@ -26,7 +26,7 @@ describe("convertLicensePlateToMercosul", () => { expect(convertLicensePlateToMercosul("ABC1D23")).toBe(""); }); - test("when it is a Mercosul motorcycle plate", () => { + test("when it is the withdrawn LLLNNLN sequence", () => { expect(convertLicensePlateToMercosul("ABC12D3")).toBe(""); }); diff --git a/src/convert-license-plate-to-mercosul/convert-license-plate-to-mercosul.ts b/src/convert-license-plate-to-mercosul/convert-license-plate-to-mercosul.ts index bb9b9709..13ccd267 100644 --- a/src/convert-license-plate-to-mercosul/convert-license-plate-to-mercosul.ts +++ b/src/convert-license-plate-to-mercosul/convert-license-plate-to-mercosul.ts @@ -20,11 +20,14 @@ import { DIGIT_TO_MERCOSUL_LETTER } from "./constants"; * convertLicensePlateToMercosul("invalid"); // "" * ``` * - * Resolução CONTRAN nº 969/2022, art. 2º § 4º. The linked DOU PDF has no annexes; the digit to - * letter table comes from Anexo II, published separately on the CONTRAN resolutions page. + * Resolução CONTRAN nº 969/2022, art. 2º § 4º, is what requires the substitution of the second + * numeric character, "conforme padrão previsto no Anexo II". Anexo II is the digit to letter + * table, and it prints the same worked example as above: "A placa anterior ABC1234 será + * substituída pela nova placa com o padrão alfanumérico ABC1C34". The annexes are published in a + * PDF of their own, separate from the resolution's text; both are cited below. * * @see Official: https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes/resolucao9692022.pdf - * @see Official: https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes + * @see Official: https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes/resolucao9692022anexos.pdf */ export const convertLicensePlateToMercosul = (value: string): string => { if (getFormatLicensePlate(value) !== "LLLNNNN") return ""; diff --git a/src/convert-number-to-words/convert-number-to-words.test.ts b/src/convert-number-to-words/convert-number-to-words.test.ts index 33c1dc7a..ee24e807 100644 --- a/src/convert-number-to-words/convert-number-to-words.test.ts +++ b/src/convert-number-to-words/convert-number-to-words.test.ts @@ -3,7 +3,6 @@ import * as fc from "fast-check"; import { NUMBER_TO_WORDS_MAX_VALUE, type NumberToWordsGender, - type WordsCase, } from "../_internals/number-to-words/number-to-words"; import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; import { convertNumberToWords, type ConvertNumberToWordsOptions } from "./convert-number-to-words"; @@ -43,8 +42,8 @@ describe("convertNumberToWords", () => { test("should convert the maximum supported value (999999999999999, 999 trillion)", () => { expect(convertNumberToWords(NUMBER_TO_WORDS_MAX_VALUE)).toBe( - "novecentos e noventa e nove trilhões, novecentos e noventa e nove bilhões, " + - "novecentos e noventa e nove milhões, novecentos e noventa e nove mil, " + + "novecentos e noventa e nove trilhões novecentos e noventa e nove bilhões " + + "novecentos e noventa e nove milhões novecentos e noventa e nove mil " + "novecentos e noventa e nove", ); }); @@ -88,29 +87,11 @@ describe("convertNumberToWords", () => { }); }); - describe("case option", () => { - test("should keep the result lowercase by default", () => { + describe("letter case", () => { + test("should always keep the result lowercase", () => { expect(convertNumberToWords(123)).toBe("cento e vinte e três"); - }); - - test("should keep the result lowercase for 'lower'", () => { - expect(convertNumberToWords(123, { case: "lower" })).toBe("cento e vinte e três"); - }); - - test("should capitalize only the first letter for 'sentence'", () => { - expect(convertNumberToWords(123, { case: "sentence" })).toBe("Cento e vinte e três"); - expect(convertNumberToWords(3, { case: "sentence" })).toBe("Três"); - }); - - test("should uppercase everything for 'upper', keeping accents", () => { - expect(convertNumberToWords(3, { case: "upper" })).toBe("TRÊS"); - expect(convertNumberToWords(50, { case: "upper" })).toBe("CINQUENTA"); - expect(convertNumberToWords(-3, { case: "upper" })).toBe("MENOS TRÊS"); - }); - - test("should ignore an invalid case value and fall back to 'lower'", () => { - // @ts-expect-error: intentionally invalid input - expect(convertNumberToWords(123, { case: "invalid" })).toBe("cento e vinte e três"); + expect(convertNumberToWords(3)).toBe("três"); + expect(convertNumberToWords(-3)).toBe("menos três"); }); }); @@ -200,7 +181,7 @@ describe("convertNumberToWords", () => { [111, "cento e onze"], [112, "cento e doze"], [113, "cento e treze"], - [114, "cento e catorze"], + [114, "cento e quatorze"], [115, "cento e quinze"], [116, "cento e dezesseis"], [117, "cento e dezessete"], @@ -323,48 +304,48 @@ describe("convertNumberToWords", () => { [1001, "mil e um"], [1021, "mil e vinte e um"], [1100, "mil e cem"], - [1101, "mil, cento e um"], + [1101, "mil cento e um"], [1200, "mil e duzentos"], - [1235, "mil, duzentos e trinta e cinco"], - [1999, "mil, novecentos e noventa e nove"], + [1235, "mil duzentos e trinta e cinco"], + [1999, "mil novecentos e noventa e nove"], [2000, "dois mil"], [2001, "dois mil e um"], [5000, "cinco mil"], - [9999, "nove mil, novecentos e noventa e nove"], + [9999, "nove mil novecentos e noventa e nove"], [10_000, "dez mil"], [21_000, "vinte e um mil"], [100_000, "cem mil"], [101_000, "cento e um mil"], [200_000, "duzentos mil"], [300_000, "trezentos mil"], - [999_999, "novecentos e noventa e nove mil, novecentos e noventa e nove"], + [999_999, "novecentos e noventa e nove mil novecentos e noventa e nove"], [1_000_000, "um milhão"], [1_000_001, "um milhão e um"], [1_000_100, "um milhão e cem"], - [1_000_230, "um milhão, duzentos e trinta"], - [1_045_678, "um milhão, quarenta e cinco mil, seiscentos e setenta e oito"], + [1_000_230, "um milhão duzentos e trinta"], + [1_045_678, "um milhão quarenta e cinco mil seiscentos e setenta e oito"], [1_100_000, "um milhão e cem mil"], [1_200_000, "um milhão e duzentos mil"], - [1_230_000, "um milhão, duzentos e trinta mil"], - [1_230_045, "um milhão, duzentos e trinta mil e quarenta e cinco"], - [1_230_456, "um milhão, duzentos e trinta mil, quatrocentos e cinquenta e seis"], + [1_230_000, "um milhão duzentos e trinta mil"], + [1_230_045, "um milhão duzentos e trinta mil e quarenta e cinco"], + [1_230_456, "um milhão duzentos e trinta mil quatrocentos e cinquenta e seis"], [2_000_000, "dois milhões"], [1_000_000_000, "um bilhão"], [1_000_000_001, "um bilhão e um"], [2_000_000_000, "dois bilhões"], [ 1_234_567_890, - "um bilhão, duzentos e trinta e quatro milhões, quinhentos e sessenta e sete mil, oitocentos e noventa", + "um bilhão duzentos e trinta e quatro milhões quinhentos e sessenta e sete mil oitocentos e noventa", ], [ 999_999_999_999, - "novecentos e noventa e nove bilhões, novecentos e noventa e nove milhões, novecentos e noventa e nove mil, novecentos e noventa e nove", + "novecentos e noventa e nove bilhões novecentos e noventa e nove milhões novecentos e noventa e nove mil novecentos e noventa e nove", ], [1_000_000_000_000, "um trilhão"], [2_000_000_000_000, "dois trilhões"], [ 999_999_999_999_999, - "novecentos e noventa e nove trilhões, novecentos e noventa e nove bilhões, novecentos e noventa e nove milhões, novecentos e noventa e nove mil, novecentos e noventa e nove", + "novecentos e noventa e nove trilhões novecentos e noventa e nove bilhões novecentos e noventa e nove milhões novecentos e noventa e nove mil novecentos e noventa e nove", ], ]; expectWords(cases); @@ -385,7 +366,7 @@ describe("convertNumberToWords", () => { [-11, "menos onze"], [-12, "menos doze"], [-13, "menos treze"], - [-14, "menos catorze"], + [-14, "menos quatorze"], [-15, "menos quinze"], [-16, "menos dezesseis"], [-17, "menos dezessete"], @@ -486,7 +467,7 @@ describe("convertNumberToWords", () => { [-1_000_000, "menos um milhão"], [ -999_999_999_999_999, - "menos novecentos e noventa e nove trilhões, novecentos e noventa e nove bilhões, novecentos e noventa e nove milhões, novecentos e noventa e nove mil, novecentos e noventa e nove", + "menos novecentos e noventa e nove trilhões novecentos e noventa e nove bilhões novecentos e noventa e nove milhões novecentos e noventa e nove mil novecentos e noventa e nove", ], ]; expectWords(cases); @@ -508,7 +489,7 @@ describe("convertNumberToWords", () => { [11, "onze"], [12, "doze"], [13, "treze"], - [14, "catorze"], + [14, "quatorze"], [15, "quinze"], [16, "dezesseis"], [17, "dezessete"], @@ -546,7 +527,7 @@ describe("convertNumberToWords", () => { [1000, "mil"], [1001, "mil e uma"], [1100, "mil e cem"], - [1101, "mil, cento e uma"], + [1101, "mil cento e uma"], [2000, "duas mil"], [2002, "duas mil e duas"], [3000, "três mil"], @@ -599,14 +580,12 @@ describe("convertNumberToWords", () => { ); }); - test("should uppercase the result the same way as the lower case result, for the 'upper' case option", () => { + test("should never return a character in upper case", () => { fc.assert( fc.property(inRangeIntegerArbitrary, (value) => { - const lower = convertNumberToWords(value); + const words = convertNumberToWords(value); - expect(convertNumberToWords(value, { case: "upper" })).toBe( - lower.toLocaleUpperCase("pt-BR"), - ); + expect(words).toBe(words.toLocaleLowerCase("pt-BR")); }), ); }); @@ -622,7 +601,6 @@ describe("convertNumberToWords types", () => { expectTypeOf().toEqualTypeOf< NumberToWordsGender | undefined >(); - expectTypeOf().toEqualTypeOf(); expectTypeOf(convertNumberToWords).returns.toEqualTypeOf(); }); }); diff --git a/src/convert-number-to-words/convert-number-to-words.ts b/src/convert-number-to-words/convert-number-to-words.ts index 1e8ac3ae..3ba3e4ae 100644 --- a/src/convert-number-to-words/convert-number-to-words.ts +++ b/src/convert-number-to-words/convert-number-to-words.ts @@ -1,22 +1,18 @@ -import { applyWordsCase } from "../_internals/apply-words-case/apply-words-case"; import { NUMBER_TO_WORDS_MAX_VALUE, type NumberToWordsGender, numberToWords, - type WordsCase, } from "../_internals/number-to-words/number-to-words"; /** Options of `convertNumberToWords`. */ export type ConvertNumberToWordsOptions = { /** Grammatical gender used to agree "um/dois" and the hundreds group ("duzentos/duzentas", etc.) with the noun the number qualifies. Defaults to `"masculine"`. */ gender?: NumberToWordsGender; - /** Letter case applied to the result: `"lower"` (unchanged), `"sentence"` (capitalizes only the first letter) or `"upper"` (uppercases everything, keeping accents). Defaults to `"lower"`; an invalid value is ignored and `"lower"` is used instead. */ - case?: WordsCase; }; /** * Formats an integer as its Brazilian Portuguese cardinal number words ("por extenso"), - * e.g. `1235` becomes `"mil, duzentos e trinta e cinco"`. + * e.g. `1235` becomes `"mil duzentos e trinta e cinco"`. * * Only integers from `-999999999999999` to `999999999999999` (999 trillion in absolute value, * the highest value expressible with the "trilhão" scale word) are supported; anything outside @@ -25,10 +21,11 @@ export type ConvertNumberToWordsOptions = { * only writes out whole numbers, it never spells out a decimal part (use * `convertCurrencyToWords` for a monetary amount with cents). * + * The result is always lowercase; apply any other casing to it yourself. + * * @param {number} value - The integer to convert. * @param {ConvertNumberToWordsOptions} [options] - Optional formatting options. * @param {NumberToWordsGender} [options.gender] - Grammatical gender for "um/dois" and the hundreds group. Defaults to `"masculine"`. - * @param {WordsCase} [options.case] - Letter case applied to the result. Defaults to `"lower"`. * @returns {string} The cardinal number written out in Portuguese, or `""` for invalid input. * * @example @@ -38,11 +35,12 @@ export type ConvertNumberToWordsOptions = { * convertNumberToWords(2000000); // "dois milhões" * convertNumberToWords(-42); // "menos quarenta e dois" * convertNumberToWords(2, { gender: "feminine" }); // "duas" - * convertNumberToWords(3, { case: "upper" }); // "TRÊS" + * convertNumberToWords(12.9); // "doze" (truncated toward zero) * convertNumberToWords(NaN); // "" * ``` * - * @see Based on: https://github.com/savoirfairelinux/num2words `brutils` itself has no dedicated + * @see Based on: https://github.com/savoirfairelinux/num2words + * `brutils` itself has no dedicated * number-to-words module (its `currency.py` delegates the Portuguese numeral text to this * library's `pt_BR` locale); this is the reference for the numeral-word tables reproduced here. */ @@ -57,7 +55,6 @@ export const convertNumberToWords = ( if (Math.abs(truncated) > NUMBER_TO_WORDS_MAX_VALUE) return ""; const words = numberToWords(Math.abs(truncated), { gender: options?.gender }); - const result = truncated < 0 ? `menos ${words}` : words; - return applyWordsCase(result, options?.case); + return truncated < 0 ? `menos ${words}` : words; }; diff --git a/src/difference-in-business-days/difference-in-business-days.test.ts b/src/difference-in-business-days/difference-in-business-days.test.ts index 3f3b92da..5669113d 100644 --- a/src/difference-in-business-days/difference-in-business-days.test.ts +++ b/src/difference-in-business-days/difference-in-business-days.test.ts @@ -1,100 +1,65 @@ import * as fc from "fast-check"; -import { type StateCode } from "../_internals/constants/states"; -import { businessDayDates } from "../_internals/test/arbitraries"; -import { expectNeverThrows } from "../_internals/test/properties"; +import { + anyBusinessDayOptions, + businessDayDates, + PROTOTYPE_KEYS, +} from "../_internals/test/arbitraries"; +import { expectNeverThrowsWithArguments } from "../_internals/test/properties"; import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; import { addBusinessDays } from "../add-business-days/add-business-days"; -import { isBusinessDay } from "../is-business-day/is-business-day"; -import { - differenceInBusinessDays, - type DifferenceInBusinessDaysParams, -} from "./difference-in-business-days"; +import { type BusinessDayOptions, isBusinessDay } from "../is-business-day/is-business-day"; +import { differenceInBusinessDays } from "./difference-in-business-days"; describe("differenceInBusinessDays", () => { - it("should return 0 for the same calendar day", () => { - const result = differenceInBusinessDays({ - from: new Date(2024, 0, 2, 9), - to: new Date(2024, 0, 2, 18), - }); + it("should match the date-fns differenceInBusinessDays example (2014-07-20 minus 2014-01-10 is 136 weekdays, https://date-fns.org/docs/differenceInBusinessDays) minus the 5 Brazilian holidays that fall on a weekday in between (Carnaval, Sexta-feira Santa, Tiradentes, Dia do trabalhador and Corpus Christi)", () => { + const result = differenceInBusinessDays(new Date(2014, 6, 20), new Date(2014, 0, 10)); - expect(result).toBe(0); + expect(result).toBe(131); }); - it("should count the from day when it is a business day and exclude the to day (Tue 2024-01-02 to Wed 2024-01-03)", () => { - const result = differenceInBusinessDays({ - from: new Date(2024, 0, 2), - to: new Date(2024, 0, 3), - }); - - expect(result).toBe(1); + it("should return 0 for the same calendar day", () => { + expect(differenceInBusinessDays(new Date(2024, 0, 2, 18), new Date(2024, 0, 2, 9))).toBe(0); }); - it("should not count the from day when it is a holiday (2024-01-01 Ano novo to 2024-01-02)", () => { - const result = differenceInBusinessDays({ - from: new Date(2024, 0, 1), - to: new Date(2024, 0, 2), - }); - - expect(result).toBe(0); + it("should count the earlier date when it is a business day and exclude the later one (Tue 2024-01-02 to Wed 2024-01-03)", () => { + expect(differenceInBusinessDays(new Date(2024, 0, 3), new Date(2024, 0, 2))).toBe(1); }); - it("should skip weekends between from and to (Fri 2024-01-05 to Mon 2024-01-08)", () => { - const result = differenceInBusinessDays({ - from: new Date(2024, 0, 5), - to: new Date(2024, 0, 8), - }); - - expect(result).toBe(1); + it("should not count the earlier date when it is a holiday (2024-01-01 Ano novo to 2024-01-02)", () => { + expect(differenceInBusinessDays(new Date(2024, 0, 2), new Date(2024, 0, 1))).toBe(0); }); - it("should ignore the time of day of both from and to", () => { - const result = differenceInBusinessDays({ - from: new Date(2024, 0, 2, 23, 59), - to: new Date(2024, 0, 3, 0, 1), - }); + it("should skip the weekend in between (Fri 2024-01-05 to Mon 2024-01-08)", () => { + expect(differenceInBusinessDays(new Date(2024, 0, 8), new Date(2024, 0, 5))).toBe(1); + }); - expect(result).toBe(1); + it("should ignore the time of day of both dates", () => { + expect(differenceInBusinessDays(new Date(2024, 0, 3, 0, 1), new Date(2024, 0, 2, 23, 59))).toBe( + 1, + ); }); describe("supported years", () => { - it("should return null when from or to is outside 1900-2099", () => { - expect( - differenceInBusinessDays({ from: new Date(2100, 0, 4), to: new Date(2100, 0, 5) }), - ).toBeNull(); - expect( - differenceInBusinessDays({ from: new Date(2099, 11, 31), to: new Date(2100, 0, 4) }), - ).toBeNull(); - expect( - differenceInBusinessDays({ from: new Date(1899, 11, 29), to: new Date(1900, 0, 2) }), - ).toBeNull(); + it("should return null when either date is outside 1900-2099", () => { + expect(differenceInBusinessDays(new Date(2100, 0, 5), new Date(2100, 0, 4))).toBeNull(); + expect(differenceInBusinessDays(new Date(2100, 0, 4), new Date(2099, 11, 31))).toBeNull(); + expect(differenceInBusinessDays(new Date(1900, 0, 2), new Date(1899, 11, 29))).toBeNull(); }); it("should accept the inclusive boundary years 1900 and 2099 (same-day range, so the result is 0 rather than null)", () => { - expect( - differenceInBusinessDays({ from: new Date(1900, 0, 2), to: new Date(1900, 0, 2) }), - ).toBe(0); - expect( - differenceInBusinessDays({ from: new Date(2099, 0, 2), to: new Date(2099, 0, 2) }), - ).toBe(0); + expect(differenceInBusinessDays(new Date(1900, 0, 2), new Date(1900, 0, 2))).toBe(0); + expect(differenceInBusinessDays(new Date(2099, 0, 2), new Date(2099, 0, 2))).toBe(0); }); }); - describe("negative results", () => { - it("should return a negative number when to is before from (Wed 2024-01-03 to Tue 2024-01-02)", () => { - const result = differenceInBusinessDays({ - from: new Date(2024, 0, 3), - to: new Date(2024, 0, 2), - }); - - expect(result).toBe(-1); + describe("sign convention", () => { + it("should return a negative number when the later date is actually before the earlier one (Tue 2024-01-02 given as laterDate, Wed 2024-01-03 as earlierDate)", () => { + expect(differenceInBusinessDays(new Date(2024, 0, 2), new Date(2024, 0, 3))).toBe(-1); }); - it("should return positive zero, not negative zero, when there are no business days walking backwards (Sun 2024-01-07 to Sat 2024-01-06)", () => { - const result = differenceInBusinessDays({ - from: new Date(2024, 0, 7), - to: new Date(2024, 0, 6), - }); + it("should return positive zero, not negative zero, when there is no business day to count walking backwards (Sat 2024-01-06 given as laterDate, Sun 2024-01-07 as earlierDate)", () => { + const result = differenceInBusinessDays(new Date(2024, 0, 6), new Date(2024, 0, 7)); expect(result).toBe(0); expect(Object.is(result, -0)).toBe(false); @@ -102,51 +67,32 @@ describe("differenceInBusinessDays", () => { }); describe("national holidays and year boundaries", () => { - it("should count business days across a year boundary, skipping Ano novo (2024-12-30 Mon to 2025-01-03 Fri)", () => { - const result = differenceInBusinessDays({ - from: new Date(2024, 11, 30), - to: new Date(2025, 0, 3), - }); - - expect(result).toBe(3); + it("should count business days across a year boundary, skipping Ano novo (Mon 2024-12-30 to Fri 2025-01-03)", () => { + expect(differenceInBusinessDays(new Date(2025, 0, 3), new Date(2024, 11, 30))).toBe(3); }); }); describe("state holidays", () => { it("should skip a state holiday when stateCode is provided (SP, Revolução Constitucionalista 2024-07-09)", () => { - const result = differenceInBusinessDays({ - from: new Date(2024, 6, 8), - to: new Date(2024, 6, 10), + const result = differenceInBusinessDays(new Date(2024, 6, 10), new Date(2024, 6, 8), { stateCode: "SP", }); expect(result).toBe(1); }); - it("should not skip that date when stateCode is not provided", () => { - const result = differenceInBusinessDays({ - from: new Date(2024, 6, 8), - to: new Date(2024, 6, 10), - }); - - expect(result).toBe(2); + it("should not skip that date when no options are provided", () => { + expect(differenceInBusinessDays(new Date(2024, 6, 10), new Date(2024, 6, 8))).toBe(2); }); }); describe("includeOptional", () => { it("should skip Carnaval 2024-02-13 by default (includeOptional defaults to true)", () => { - const result = differenceInBusinessDays({ - from: new Date(2024, 1, 12), - to: new Date(2024, 1, 14), - }); - - expect(result).toBe(1); + expect(differenceInBusinessDays(new Date(2024, 1, 14), new Date(2024, 1, 12))).toBe(1); }); it("should count Carnaval 2024-02-13 as a business day when includeOptional is false", () => { - const result = differenceInBusinessDays({ - from: new Date(2024, 1, 12), - to: new Date(2024, 1, 14), + const result = differenceInBusinessDays(new Date(2024, 1, 14), new Date(2024, 1, 12), { includeOptional: false, }); @@ -155,104 +101,105 @@ describe("differenceInBusinessDays", () => { }); describe("invalid input", () => { - it("should return null when params is null", () => { - // @ts-expect-error: intentionally invalid input - expect(differenceInBusinessDays(null)).toBeNull(); - }); - - it("should return null when params is undefined", () => { + it("should return null when called without arguments", () => { // @ts-expect-error: intentionally invalid input expect(differenceInBusinessDays()).toBeNull(); }); - it("should return null when params is not an object", () => { + it("should return null when the later date is null", () => { // @ts-expect-error: intentionally invalid input - expect(differenceInBusinessDays("2024-01-02")).toBeNull(); + expect(differenceInBusinessDays(null, new Date(2024, 0, 2))).toBeNull(); }); - it('should return null when params is a function, even one carrying from/to properties (typeof params !== "object" must reject it, not just isNullish)', () => { - const fakeParams = Object.assign(() => null, { - from: new Date(2024, 0, 2), - to: new Date(2024, 0, 3), - }); - - expect(differenceInBusinessDays(fakeParams)).toBeNull(); + it("should return null when the later date is an invalid Date", () => { + expect(differenceInBusinessDays(new Date("not a date"), new Date(2024, 0, 2))).toBeNull(); }); - it("should return null when from is an invalid Date", () => { - expect( - differenceInBusinessDays({ from: new Date("not a date"), to: new Date(2024, 0, 2) }), - ).toBeNull(); + it("should return null when the earlier date is an invalid Date", () => { + expect(differenceInBusinessDays(new Date(2024, 0, 2), new Date("not a date"))).toBeNull(); }); - it("should return null when to is an invalid Date", () => { - expect( - differenceInBusinessDays({ from: new Date(2024, 0, 2), to: new Date("not a date") }), - ).toBeNull(); + it("should return null when the later date is not a Date", () => { + // @ts-expect-error: intentionally invalid input + expect(differenceInBusinessDays("2024-01-03", new Date(2024, 0, 2))).toBeNull(); }); - it("should return null when from is not a Date", () => { - expect( - // @ts-expect-error: intentionally invalid input - differenceInBusinessDays({ from: "2024-01-02", to: new Date(2024, 0, 3) }), - ).toBeNull(); + it("should return null when the earlier date is not a Date", () => { + // @ts-expect-error: intentionally invalid input + expect(differenceInBusinessDays(new Date(2024, 0, 3), "2024-01-02")).toBeNull(); }); - it("should return null when to is not a Date", () => { - expect( + it("should return null when the stateCode is not a string", () => { + const result = differenceInBusinessDays(new Date(2024, 0, 3), new Date(2024, 0, 2), { // @ts-expect-error: intentionally invalid input - differenceInBusinessDays({ from: new Date(2024, 0, 2), to: "2024-01-03" }), - ).toBeNull(); + stateCode: 11, + }); + + expect(result).toBeNull(); }); - it("should return null when stateCode is not a string", () => { - expect( - differenceInBusinessDays({ - from: new Date(2024, 0, 2), - to: new Date(2024, 0, 3), - // @ts-expect-error: intentionally invalid input - stateCode: 11, - }), - ).toBeNull(); + it("should ignore options that are not an object", () => { + // @ts-expect-error: intentionally invalid input + expect(differenceInBusinessDays(new Date(2024, 6, 10), new Date(2024, 6, 8), "SP")).toBe(2); }); it("should ignore a stateCode that is not a known state", () => { - const result = differenceInBusinessDays({ - from: new Date(2024, 0, 2), - to: new Date(2024, 0, 3), + const result = differenceInBusinessDays(new Date(2024, 0, 3), new Date(2024, 0, 2), { // @ts-expect-error: intentionally invalid input stateCode: "XX", }); expect(result).toBe(1); }); + + it("should treat a prototype chain key as an unknown stateCode instead of throwing", () => { + for (const stateCode of PROTOTYPE_KEYS) { + expect( + differenceInBusinessDays(new Date(2024, 0, 3), new Date(2024, 0, 2), { + // @ts-expect-error: intentionally invalid input + stateCode, + }), + ).toBe(1); + } + }); }); describe("properties", () => { - const daysArbitrary = fc.integer({ min: -100, max: 100 }); + const amounts = fc.integer({ min: -100, max: 100 }); + + test("should never throw, regardless of the input, prototype chain state codes included", () => { + // The walk visits every day between the two dates, so the dates that are dates stay inside a + // few years: a pair a century apart is thousands of iterations per run, which is what the + // other properties already cover and what made this one time out under mutation testing. + const anyNearDate = fc.oneof( + fc.date({ min: new Date(2020, 0, 1), max: new Date(2026, 11, 31), noInvalidDate: true }), + fc.anything(), + ); - test("should never throw, regardless of the input", () => { - expectNeverThrows(differenceInBusinessDays, fc.anything()); + expectNeverThrowsWithArguments( + differenceInBusinessDays, + fc.tuple(anyNearDate, anyNearDate, anyBusinessDayOptions), + ); }); test("should return 0 for the same calendar day", () => { fc.assert( fc.property(businessDayDates, (date) => { - expect(differenceInBusinessDays({ from: date, to: date })).toBe(0); + expect(differenceInBusinessDays(date, date)).toBe(0); }), ); }); test("should undo addBusinessDays when starting from a business day", () => { fc.assert( - fc.property(businessDayDates, daysArbitrary, (from, days) => { - if (!isBusinessDay(from)) return; + fc.property(businessDayDates, amounts, (earlierDate, amount) => { + if (!isBusinessDay(earlierDate)) return; - const to = addBusinessDays({ date: from, days }); + const laterDate = addBusinessDays(earlierDate, amount); - if (to === null) return; + if (laterDate === null) return; - expect(differenceInBusinessDays({ from, to })).toBe(days); + expect(differenceInBusinessDays(laterDate, earlierDate)).toBe(amount); }), ); }); @@ -260,16 +207,12 @@ describe("differenceInBusinessDays", () => { }); describe("differenceInBusinessDays types", () => { - test("should take a DifferenceInBusinessDaysParams and return a number or null", () => { + test("should take two Dates and optional BusinessDayOptions, and return a number or null", () => { + expectTypeOf(differenceInBusinessDays).parameter(0).toEqualTypeOf(); + expectTypeOf(differenceInBusinessDays).parameter(1).toEqualTypeOf(); expectTypeOf(differenceInBusinessDays) - .parameter(0) - .toEqualTypeOf(); - expectTypeOf().toEqualTypeOf<{ - from: Date; - to: Date; - stateCode?: StateCode; - includeOptional?: boolean; - }>(); + .parameter(2) + .toEqualTypeOf(); expectTypeOf(differenceInBusinessDays).returns.toEqualTypeOf(); }); }); diff --git a/src/difference-in-business-days/difference-in-business-days.ts b/src/difference-in-business-days/difference-in-business-days.ts index ad93147e..01cdb633 100644 --- a/src/difference-in-business-days/difference-in-business-days.ts +++ b/src/difference-in-business-days/difference-in-business-days.ts @@ -1,25 +1,7 @@ -import { HOLIDAYS_MAX_YEAR, HOLIDAYS_MIN_YEAR } from "../_internals/constants/holidays"; -import { type StateCode } from "../_internals/constants/states"; -import { isNullish } from "../_internals/is-nullish/is-nullish"; -import { isBusinessDay } from "../is-business-day/is-business-day"; +import { isSupportedHolidayYear } from "../_internals/is-supported-holiday-year/is-supported-holiday-year"; +import { type BusinessDayOptions, isBusinessDay } from "../is-business-day/is-business-day"; -/** The parameters `differenceInBusinessDays` takes: the two dates to count between and which holidays count. */ -export type DifferenceInBusinessDaysParams = { - /** The date to count from. Counted as a business day when it is one; never mutated. */ - from: Date; - /** The date to count to. Never counted itself, regardless of whether it is a business day. */ - to: Date; - /** Two letter state code whose state holidays are also treated as non-business days (default: national holidays only). */ - stateCode?: StateCode; - /** Whether optional-type holidays (e.g. Carnaval, Corpus Christi) count as non-business days (default: `true`, matching Brazilian banking practice). */ - includeOptional?: boolean; -}; - -const isSupportedYear = (date: Date): boolean => { - const year = date.getFullYear(); - - return year >= HOLIDAYS_MIN_YEAR && year <= HOLIDAYS_MAX_YEAR; -}; +export type { BusinessDayOptions } from "../is-business-day/is-business-day"; const toLocalDayTimestamp = (date: Date): number => Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()); @@ -28,74 +10,86 @@ const toLocalDayTimestamp = (date: Date): number => * Counts the number of Brazilian business days (dias úteis) between two dates. * * Mirrors the semantics of date-fns' `differenceInBusinessDays`, verified against its source - * (`differenceInBusinessDays.js` in the `date-fns` package): the day at `from` is counted when - * it is itself a business day, the day at `to` is never counted, and every business day - * strictly in between is counted once. Concretely, the function walks one calendar day at a - * time from `from` towards `to` (or the other way around, when `to` is before `from`), adding - * one for every day that `isBusinessDay` accepts, stopping just before reaching `to`. Only the - * calendar day of each `Date` matters, exactly like `differenceInCalendarDays`: the time of day - * is ignored. + * (`differenceInBusinessDays.js` in the `date-fns` package), argument order included: the walk + * starts at `earlierDate` and stops just before `laterDate`, so **`earlierDate` is counted when + * it is itself a business day and `laterDate` is never counted**, whatever their order, and every + * business day strictly in between is counted once. Only the calendar day of each `Date` matters, + * exactly like `differenceInCalendarDays`: the time of day is ignored. * - * A business day is a day for which `isBusinessDay` returns `true` (not a Saturday, a Sunday, - * or a Brazilian holiday), evaluated with the same `stateCode`/`includeOptional` options. + * The result is positive when `laterDate` is after `earlierDate` and negative when it is before + * it, the date-fns sign convention; two dates on the same calendar day return `0` (a positive + * zero, never `-0`). * - * `from` and `to` on the same calendar day return `0`. A `to` before `from` returns a negative - * number, mirroring date-fns. + * A business day is a day for which `isBusinessDay` returns `true` (not a Saturday, a Sunday, + * or a Brazilian holiday), evaluated with the same `options`. * - * If `stateCode` is provided but is not a valid/known state code, it is ignored and only - * national holidays are considered (same behavior as `getHolidays`/`isBusinessDay`). + * If `options.stateCode` is provided but is not a valid/known state code, it is ignored and only + * national holidays are considered (same behavior as `getHolidays`/`isBusinessDay`), so a + * prototype-chain key such as `"__proto__"` is an unknown state code like any other. An `options` + * that is not an object at all is ignored, exactly as `isBusinessDay` ignores it. * - * Only years from 1900 through 2099 are supported, the range `getHolidays` computes; a `from` - * or `to` outside it returns `null`. + * Only years from 1900 through 2099 are supported, the range `getHolidays` computes; a + * `laterDate` or `earlierDate` outside it returns `null`. * - * @param {DifferenceInBusinessDaysParams} params - The parameters of the calculation. - * @param {Date} params.from - The date to count from. - * @param {Date} params.to - The date to count to. - * @param {StateCode} [params.stateCode] - Brazilian state code whose state holidays are also considered. - * @param {boolean} [params.includeOptional] - Whether optional holidays count as non-business days (default: `true`). - * @returns {number|null} The number of business days between `from` and `to`, or `null` on bad - * input: a `params` that is not an object, a `from`/`to` that is not a valid `Date` or is - * outside 1900-2099, or a `stateCode` that is not a string. + * @param {Date} laterDate - The date to count to. Never counted itself, regardless of whether it is a business day. + * @param {Date} earlierDate - The date to count from. Counted as a business day when it is one; never mutated. + * @param {BusinessDayOptions} [options] - Which holidays count as non-business days. + * @param {StateCode} [options.stateCode] - Brazilian state code whose state holidays are also considered. + * @param {boolean} [options.includeOptional] - Whether optional holidays count as non-business days (default: `true`). + * @returns {number | null} The number of business days between the two dates, or `null` on bad + * input: a `laterDate`/`earlierDate` that is not a valid `Date` or is outside 1900-2099, or a + * `stateCode` that is not a string. * * @example * ```typescript - * differenceInBusinessDays({ from: new Date(2024, 0, 1), to: new Date(2024, 0, 2) }); // 0 (Jan 1 is Ano novo) - * differenceInBusinessDays({ from: new Date(2024, 0, 2), to: new Date(2024, 0, 3) }); // 1 (Jan 2 counted, a Tuesday) - * differenceInBusinessDays({ from: new Date(2024, 0, 3), to: new Date(2024, 0, 2) }); // -1 (to before from) - * differenceInBusinessDays({ from: new Date(2024, 0, 2), to: new Date(2024, 0, 2) }); // 0 (same day) - * differenceInBusinessDays({ from: new Date(2024, 6, 8), to: new Date(2024, 6, 10), stateCode: "SP" }); // 1 (Jul 9 is a state holiday in SP) - * differenceInBusinessDays({ from: new Date("not a date"), to: new Date() }); // null - * differenceInBusinessDays({ from: new Date(2100, 0, 4), to: new Date(2100, 0, 5) }); // null (outside the supported years) + * differenceInBusinessDays(new Date(2024, 0, 2), new Date(2024, 0, 1)); // 0 (Jan 1 is Ano novo, not counted) + * differenceInBusinessDays(new Date(2024, 0, 3), new Date(2024, 0, 2)); // 1 (Jan 2 counted, a Tuesday; Jan 3 is not) + * differenceInBusinessDays(new Date(2024, 0, 2), new Date(2024, 0, 3)); // -1 (the later date comes first, so the count is negative) + * differenceInBusinessDays(new Date(2024, 0, 2), new Date(2024, 0, 2)); // 0 (same day) + * differenceInBusinessDays(new Date(2024, 6, 10), new Date(2024, 6, 8), { stateCode: "SP" }); // 1 (Jul 9 is a state holiday in SP) + * differenceInBusinessDays(new Date(), new Date("not a date")); // null + * differenceInBusinessDays(new Date(2100, 0, 5), new Date(2100, 0, 4)); // null (outside the supported years) * ``` * - * @see Based on: https://date-fns.org/docs/differenceInBusinessDays Documented behavior. - * @see Based on: https://unpkg.com/date-fns@4.1.0/differenceInBusinessDays.js Source used to - * verify the exact boundary treatment (`from` counted, `to` excluded) and the sign convention. - * The underlying holiday determination's official sources are cited in + * @see Based on: https://date-fns.org/docs/differenceInBusinessDays + * Documented behavior and the + * positional `(laterDate, earlierDate)` argument order. + * @see Based on: https://unpkg.com/date-fns@4.1.0/differenceInBusinessDays.js + * Source used to + * verify the exact boundary treatment (`earlierDate` counted, `laterDate` excluded) and the sign + * convention. The underlying holiday determination's official sources are cited in * `isBusinessDay`/`getHolidays`. */ -export const differenceInBusinessDays = (params: DifferenceInBusinessDaysParams): number | null => { - if (isNullish(params) || typeof params !== "object") return null; +export const differenceInBusinessDays = ( + laterDate: Date, + earlierDate: Date, + options?: BusinessDayOptions, +): number | null => { + if (!(laterDate instanceof Date) || Number.isNaN(laterDate.getTime())) return null; + if (!(earlierDate instanceof Date) || Number.isNaN(earlierDate.getTime())) return null; - const { from, to, stateCode, includeOptional } = params; + const stateCode = options?.stateCode; - if (!(from instanceof Date) || Number.isNaN(from.getTime())) return null; - if (!(to instanceof Date) || Number.isNaN(to.getTime())) return null; if (stateCode !== undefined && typeof stateCode !== "string") return null; - if (!isSupportedYear(from) || !isSupportedYear(to)) return null; + if (!isSupportedHolidayYear(laterDate.getFullYear())) return null; + if (!isSupportedHolidayYear(earlierDate.getFullYear())) return null; - const fromDay = toLocalDayTimestamp(from); - const toDay = toLocalDayTimestamp(to); + const laterDay = toLocalDayTimestamp(laterDate); + const earlierDay = toLocalDayTimestamp(earlierDate); - // Stryker disable next-line EqualityOperator: when fromDay equals toDay, the loop below never runs (movingDate already equals toDay), so < vs <= here is unobservable - const step = fromDay < toDay ? 1 : -1; - const movingDate = new Date(from.getFullYear(), from.getMonth(), from.getDate()); + // Stryker disable next-line EqualityOperator: when the two days are equal, the loop below never runs (movingDate already equals laterDay), so < vs <= here is unobservable + const step = earlierDay < laterDay ? 1 : -1; + const movingDate = new Date( + earlierDate.getFullYear(), + earlierDate.getMonth(), + earlierDate.getDate(), + ); let result = 0; - while (toLocalDayTimestamp(movingDate) !== toDay) { - if (isBusinessDay(movingDate, { stateCode, includeOptional })) result += step; + while (toLocalDayTimestamp(movingDate) !== laterDay) { + if (isBusinessDay(movingDate, options)) result += step; movingDate.setDate(movingDate.getDate() + step); } diff --git a/src/format-boleto/format-boleto.test.ts b/src/format-boleto/format-boleto.test.ts index 68925f64..78c4581f 100644 --- a/src/format-boleto/format-boleto.test.ts +++ b/src/format-boleto/format-boleto.test.ts @@ -213,6 +213,15 @@ describe("formatBoleto", () => { }); }); +describe("formatBoleto with a nullish value under pad", () => { + test("should return an empty string instead of a zero-filled document", () => { + // @ts-expect-error: intentionally invalid input + expect(formatBoleto(null, { pad: true })).toBe(""); + // @ts-expect-error: intentionally invalid input + expect(formatBoleto(undefined, { pad: true })).toBe(""); + }); +}); + describe("formatBoleto types", () => { test("should take a string or number, optional options, and return a string", () => { expectTypeOf(formatBoleto).parameter(0).toEqualTypeOf(); diff --git a/src/format-boleto/format-boleto.ts b/src/format-boleto/format-boleto.ts index f643f5be..3be6ad91 100644 --- a/src/format-boleto/format-boleto.ts +++ b/src/format-boleto/format-boleto.ts @@ -33,12 +33,14 @@ export type FormatBoletoOptions = { * // "82630000001-1 09880010070-2 02410202400-0 00020510451-9" * ``` * - * Carta-Circular BCB nº 2.926/2000 specifies the linha digitável fields, the módulo 11 check - * digit (using 1 for remainders 0, 10 and 1) and the fator de vencimento behind the 47 digit - * cobrança bancária slip; the FEBRABAN layout index covers the arrecadação slip. + * Carta-Circular BCB nº 2.926/2000 specifies the linha digitável fields and the módulo 11 + * check digit (using 1 for remainders 0, 10 and 1) of the 47 digit cobrança bancária slip, + * including the position of the fator de vencimento field. The FEBRABAN "Layout Padrão de + * Arrecadação/Recebimento com Utilização do Código de Barras" and the FEBRABAN layout index + * cover the arrecadação slip. * - * @see Official: https://cmsarquivos.febraban.org.br/Arquivos/documentos/PDF/Layout%20-%20C%C3%B3digo%20de%20Barras%20-%20Vers%C3%A3o%208%20-%2011_05_2026.pdf * @see Official: https://www.bcb.gov.br/pre/normativos/c_circ/2000/pdf/c_circ_2926_v1_O.pdf + * @see Official: https://cmsarquivos.febraban.org.br/Arquivos/documentos/PDF/Layout%20-%20C%C3%B3digo%20de%20Barras%20-%20Vers%C3%A3o%208%20-%2011_05_2026.pdf * @see Official: https://portal.febraban.org.br/pagina/3425/33/pt-br/layout-febraban */ export const formatBoleto = (value: string | number, options?: FormatBoletoOptions): string => { diff --git a/src/format-caepf/format-caepf.test.ts b/src/format-caepf/format-caepf.test.ts index b1eb6bb5..d202767f 100644 --- a/src/format-caepf/format-caepf.test.ts +++ b/src/format-caepf/format-caepf.test.ts @@ -81,6 +81,15 @@ describe("formatCaepf", () => { }); }); +describe("formatCaepf with a nullish value under pad", () => { + test("should return an empty string instead of a zero-filled document", () => { + // @ts-expect-error: intentionally invalid input + expect(formatCaepf(null, { pad: true })).toBe(""); + // @ts-expect-error: intentionally invalid input + expect(formatCaepf(undefined, { pad: true })).toBe(""); + }); +}); + describe("formatCaepf types", () => { test("should take a string or number, optional options, and return a string", () => { expectTypeOf(formatCaepf).parameter(0).toEqualTypeOf(); diff --git a/src/format-cei/format-cei.test.ts b/src/format-cei/format-cei.test.ts index c11157f9..345a9265 100644 --- a/src/format-cei/format-cei.test.ts +++ b/src/format-cei/format-cei.test.ts @@ -77,6 +77,15 @@ describe("formatCei", () => { }); }); +describe("formatCei with a nullish value under pad", () => { + test("should return an empty string instead of a zero-filled document", () => { + // @ts-expect-error: intentionally invalid input + expect(formatCei(null, { pad: true })).toBe(""); + // @ts-expect-error: intentionally invalid input + expect(formatCei(undefined, { pad: true })).toBe(""); + }); +}); + describe("formatCei types", () => { test("should take a string or number, optional options, and return a string", () => { expectTypeOf(formatCei).parameter(0).toEqualTypeOf(); diff --git a/src/format-cei/format-cei.ts b/src/format-cei/format-cei.ts index a4fc7575..7df7bbf5 100644 --- a/src/format-cei/format-cei.ts +++ b/src/format-cei/format-cei.ts @@ -10,7 +10,9 @@ export type FormatCeiOptions = { }; /** - * Formats a CEI (Cadastro Específico do INSS) number according to the official mask. + * Formats a CEI (Cadastro Específico do INSS) number according to the usual "00.000.00000/00" + * mask, the one the reference implementations of the check digit agree on (the Receita Federal + * does not print it). * * Formats progressively, as far as the digits given go, so it can also be used as an input * mask while the user is still typing. diff --git a/src/format-certidao/format-certidao.test.ts b/src/format-certidao/format-certidao.test.ts index 38ae15e0..8717f11f 100644 --- a/src/format-certidao/format-certidao.test.ts +++ b/src/format-certidao/format-certidao.test.ts @@ -60,10 +60,9 @@ describe("formatCertidao", () => { }); }); - describe("should refuse a number", () => { - test("because the 32 digits of a matrícula do not fit in a JavaScript number", () => { - // @ts-expect-error: intentionally invalid input - expect(formatCertidao(104_539_015_520)).toBe(""); + describe("should read a number as the string of its digits, like formatCpf", () => { + test("masking it as far as it goes; a full 32 digit matrícula still has to be a string", () => { + expect(formatCertidao(104_539_015_520)).toBe("104539 01 55 20"); }); }); @@ -102,8 +101,7 @@ describe("formatCertidao", () => { fc.assert( fc.property(fc.string({ unit: "grapheme" }), fc.integer(), (text, number) => { expect(typeof formatCertidao(text)).toBe("string"); - // @ts-expect-error: intentionally invalid input - expect(formatCertidao(number)).toBe(""); + expect(typeof formatCertidao(number)).toBe("string"); }), ); }); @@ -111,8 +109,8 @@ describe("formatCertidao", () => { }); describe("formatCertidao types", () => { - test("should take a string, optional options, and return a string", () => { - expectTypeOf(formatCertidao).parameter(0).toEqualTypeOf(); + test("should take a string or number, optional options, and return a string", () => { + expectTypeOf(formatCertidao).parameter(0).toEqualTypeOf(); expectTypeOf(formatCertidao).parameter(1).toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf(formatCertidao).returns.toEqualTypeOf(); diff --git a/src/format-certidao/format-certidao.ts b/src/format-certidao/format-certidao.ts index fc0910f4..cfe354a6 100644 --- a/src/format-certidao/format-certidao.ts +++ b/src/format-certidao/format-certidao.ts @@ -1,5 +1,6 @@ import { CERTIDAO_PATTERN } from "../_internals/constants/certidao"; import { format } from "../_internals/format/format"; +import { isNullish } from "../_internals/is-nullish/is-nullish"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; /** Options of `formatCertidao`. */ @@ -12,10 +13,12 @@ export type FormatCertidaoOptions = { * Formats the matrícula of a certidão de registro civil into the printed mask of the norm, the * 32 digits grouped as 6 2 2 4 1 5 3 7 2 and separated by spaces. * - * Only a string is accepted: the 32 digits of a matrícula are more than a JavaScript number can - * hold, so a numeric argument gives an empty string instead of the digits of a rounded value. + * A number is accepted and read as the string of its digits, like in `formatCpf`, but a full 32 + * digit matrícula has to be a string: that many digits are more than a JavaScript number can hold + * exactly. At runtime the value is read for its digits and masked as far as they go, like in every + * formatter of this package, so a partial matrícula still being typed is masked progressively. * - * @param {string} value - The matrícula value to be formatted. + * @param {string|number} value - The matrícula value to be formatted. * @param {FormatCertidaoOptions} [options] - Optional formatting options. * @param {boolean} options.pad - If true, pads the value with leading zeros if necessary. * @returns {string} The formatted matrícula in the pattern "000000 00 00 0000 0 00000 000 0000000 00". @@ -30,26 +33,39 @@ export type FormatCertidaoOptions = { * * formatCertidao("1552010100020112000012087", { pad: true }); * // "000000 01 55 2010 1 00020 112 0000120 87" + * + * formatCertidao(104539015520); // "104539 01 55 20" (a number is read as the string of its digits) * ``` * - * @see Official: https://atos.cnj.jus.br/atos/detalhar/5243 Código Nacional de Normas da - * Corregedoria Nacional de Justiça - Foro Extrajudicial (Provimento CNJ nº 149/2023), art. 473 - * in the wording of the Provimento CN nº 182, de 17/09/2024: the in-force layout of the 32 - * digit matrícula. - * @see Official: https://atos.cnj.jus.br/atos/detalhar/1311 Provimento CNJ nº 2, de 27/04/2009, - * which instituted the modelos únicos de certidão and the matrícula (revoked; historical). - * @see Based on: http://ghiorzi.org/DVnew.htm Worked example of the two check digits - * (sums 288 and 309). + * @see Official: https://atos.cnj.jus.br/atos/detalhar/5243 + * Código Nacional de Normas da Corregedoria Nacional de Justiça - Foro Extrajudicial (Provimento + * CNJ nº 149/2023), art. 473 as currently published: the in-force layout of the 32 digit + * matrícula. Inciso II and §§ 1º and 3º to 5º carry the redação of the Provimento CN nº 237, de + * 13/07/2026; the rest of the article, § 2º included, and the digit layout this library depends + * on, come from the Provimento CN nº 182, de 17/09/2024. + * @see Official: https://atos.cnj.jus.br/atos/detalhar/1311 + * Provimento CNJ nº 2, de 27/04/2009, art. 1º and 2º, which instituted the modelos únicos de + * certidão and ordered that "as certidões passarão a consignar matrícula que identifica o código + * nacional da serventia, o código do acervo, o tipo do serviço prestado, o tipo do livro, o número + * do livro, o número da folha, o número do termo e o digito verificador" (revoked; historical). + * @see Official: https://atos.cnj.jus.br/atos/detalhar/1310 + * Provimento CNJ nº 3, de 17/11/2009, art. 7º, which is where that matrícula first got its digit + * structure: "a matrícula, de inserção obrigatória nas certidões (primeira e demais vias) emitidas + * pelos Cartórios de Registro Civil das Pessoas Naturais a partir de 1º de janeiro de 2010, é + * formada pelos seguintes elementos", incisos I to IX fixing the same 6 + 2 + 2 + 4 + 1 + 5 + 3 + + * 7 + 2 positions art. 473 carries today (revoked; historical). + * @see Based on: http://ghiorzi.org/DVnew.htm + * Worked example of the two check digits (sums 288 and 309). * @see Based on: https://github.com/klawdyo/validation-br/blob/feat-certidao/src/certidao.ts * Reference implementation, and the source of the matrículas used as test vectors. * @see Based on: https://github.com/geekcom/validator-docs/blob/master/src/validator-docs/Rules/Certidao.php * Third reference implementation agreeing on the weights and on the remainder of 10 read as 1. */ -export const formatCertidao = (value: string, options?: FormatCertidaoOptions): string => - typeof value === "string" - ? format({ +export const formatCertidao = (value: string | number, options?: FormatCertidaoOptions): string => + isNullish(value) + ? "" + : format({ pad: options?.pad, value: sanitizeToDigits(value), pattern: CERTIDAO_PATTERN, - }) - : ""; + }); diff --git a/src/format-cnae/format-cnae.test.ts b/src/format-cnae/format-cnae.test.ts index 4e2b5d50..45add4ab 100644 --- a/src/format-cnae/format-cnae.test.ts +++ b/src/format-cnae/format-cnae.test.ts @@ -1,11 +1,11 @@ -import { anyGarbage, digits } from "../_internals/test/arbitraries"; +import { anyGarbage, digits, digitsUpTo } from "../_internals/test/arbitraries"; import { expectIdempotent, expectMatchesPattern, expectNeverThrows, } from "../_internals/test/properties"; import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; -import { formatCnae } from "./format-cnae"; +import { formatCnae, type FormatCnaeOptions } from "./format-cnae"; describe("formatCnae", () => { it("should format a CNAE code given as digits", () => { @@ -28,9 +28,43 @@ describe("formatCnae", () => { expect(formatCnae("")).toBe(""); }); - it("should left pad a short code with zeros up to the full CNAE length", () => { - expect(formatCnae("1")).toBe("0000-0/01"); - expect(formatCnae("501")).toBe("0000-5/01"); + it("should mask a partial value progressively by default", () => { + expect(formatCnae("6")).toBe("6"); + expect(formatCnae("62")).toBe("62"); + expect(formatCnae("620")).toBe("620"); + expect(formatCnae("6201")).toBe("6201"); + expect(formatCnae("62015")).toBe("6201-5"); + expect(formatCnae("620150")).toBe("6201-5/0"); + expect(formatCnae("6201501")).toBe("6201-5/01"); + }); + + it("should mask a partial number progressively by default", () => { + expect(formatCnae(62)).toBe("62"); + expect(formatCnae(111_301)).toBe("1113-0/1"); + }); + + it("should not add digits after the CNAE length", () => { + expect(formatCnae("62015010000")).toBe("6201-5/01"); + }); + + describe("pad option", () => { + it("should left pad a short code with zeros up to the full CNAE length", () => { + expect(formatCnae("", { pad: true })).toBe("0000-0/00"); + expect(formatCnae("1", { pad: true })).toBe("0000-0/01"); + expect(formatCnae("62", { pad: true })).toBe("0000-0/62"); + expect(formatCnae("501", { pad: true })).toBe("0000-5/01"); + expect(formatCnae("62015", { pad: true })).toBe("0062-0/15"); + expect(formatCnae("6201501", { pad: true })).toBe("6201-5/01"); + }); + + it("should left pad a number the same way as its digits", () => { + expect(formatCnae(62, { pad: true })).toBe("0000-0/62"); + expect(formatCnae(111_301, { pad: true })).toBe("0111-3/01"); + }); + + it("should mask progressively for an explicit false", () => { + expect(formatCnae("62", { pad: false })).toBe("62"); + }); }); it("should return an empty string for null and undefined", () => { @@ -40,6 +74,28 @@ describe("formatCnae", () => { expect(formatCnae()).toBe(""); }); + it("should return an empty string for null and undefined even under pad, instead of a zero-filled code", () => { + // @ts-expect-error not a string or number + expect(formatCnae(null, { pad: true })).toBe(""); + // @ts-expect-error not a string or number + expect(formatCnae(undefined, { pad: true })).toBe(""); + }); + + it("should read only the digits of a value with other characters, like formatCpf", () => { + expect(formatCnae("abc6201501")).toBe("6201-5/01"); + expect(formatCnae("62.01-5/01")).toBe("6201-5/01"); + }); + + it("should read a signed or fractional number as the string of its digits, like formatCpf", () => { + expect(formatCnae(-6_201_501)).toBe("6201-5/01"); + expect(formatCnae(620_150.1)).toBe("6201-5/01"); + expect(formatCnae(2 ** 53)).toBe("9007-1/99"); + }); + + it("should return an empty string for a null-prototype object", () => { + expect(formatCnae(Object.create(null))).toBe(""); + }); + describe("properties", () => { const sevenDigitArbitrary = digits(7); @@ -51,6 +107,14 @@ describe("formatCnae", () => { expectMatchesPattern(formatCnae, /^\d{4}-\d\/\d{2}$/, sevenDigitArbitrary); }); + test("should format every shorter value in the NNNN-N/NN pattern when padding", () => { + expectMatchesPattern( + (value) => formatCnae(value, { pad: true }), + /^\d{4}-\d\/\d{2}$/, + digitsUpTo(7), + ); + }); + test("should be idempotent on a full 7 digit code", () => { expectIdempotent(formatCnae, sevenDigitArbitrary); }); @@ -58,8 +122,13 @@ describe("formatCnae", () => { }); describe("formatCnae types", () => { - test("should take a string or number and return a string", () => { + test("should take a string or number value and options and return a string", () => { expectTypeOf(formatCnae).parameter(0).toEqualTypeOf(); + expectTypeOf(formatCnae).parameter(1).toEqualTypeOf(); expectTypeOf(formatCnae).returns.toEqualTypeOf(); }); + + test("should type the pad option as an optional boolean", () => { + expectTypeOf().toEqualTypeOf(); + }); }); diff --git a/src/format-cnae/format-cnae.ts b/src/format-cnae/format-cnae.ts index 989348a6..72d6b3f5 100644 --- a/src/format-cnae/format-cnae.ts +++ b/src/format-cnae/format-cnae.ts @@ -2,13 +2,35 @@ import { format } from "../_internals/format/format"; import { isNullish } from "../_internals/is-nullish/is-nullish"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +/** Options of `formatCnae`. */ +export type FormatCnaeOptions = { + /** Whether to left pad the value with zeros up to the 7 digits of a complete subclass code (default: `false`). */ + pad?: boolean; +}; + /** * Formats a CNAE (Classificação Nacional de Atividades Econômicas) subclass code. * * This is a purely structural transformation, it does not check the code against the * official table, use `isValidCnae` for that. * + * With the default `pad: false` the mask is applied progressively, as far as the value goes, + * which is what an input being typed into needs (`"62"` stays `"62"`, `"62015"` becomes + * `"6201-5"`). With `pad: true` the value is first left padded with zeros to the 7 digits of a + * complete subclass code, so it always comes back fully masked (`"62"` gives `"0000-0/62"`). + * A number is treated exactly like the string of its digits: it is only padded under + * `pad: true`, so `formatCnae(111301)` gives `"1113-0/1"` and `formatCnae(111301, { pad: true })` + * gives `"0111-3/01"`. + * + * Like every formatter of this package, the value is read for its digits and masked as far as + * they go: characters outside the mask are dropped (`formatCnae("abc6201501")` gives + * `"6201-5/01"`) and a number is read as the string of its digits, sign and decimal point + * included (`formatCnae(-6201501)` gives `"6201-5/01"`). This is the input-mask contract of + * `formatCpf`; use `isValidCnae` to check a code. + * * @param {string|number} value - The CNAE code to be formatted. + * @param {FormatCnaeOptions} [options] - Optional formatting options. + * @param {boolean} [options.pad] - Whether to pad the value with leading zeros. Defaults to `false`. * @returns {string} The formatted code in the `NNNN-N/NN` pattern, or an empty string * when there is nothing to format. * @@ -16,15 +38,21 @@ import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-d * ```typescript * formatCnae("6201501"); // "6201-5/01" * formatCnae(6201501); // "6201-5/01" + * formatCnae("62"); // "62" (partial values are masked as far as they go) + * formatCnae("62015"); // "6201-5" + * formatCnae("62", { pad: true }); // "0000-0/62" (padded to 7 digits first) + * formatCnae("abc6201501"); // "6201-5/01" (only the digits are read) + * formatCnae(-6201501); // "6201-5/01" * ``` * * @see Official: https://servicodados.ibge.gov.br/api/v2/cnae/subclasses */ -export const formatCnae = (value: string | number): string => - isNullish(value) || value === "" - ? "" - : format({ - value: sanitizeToDigits(value), - pattern: "0000-0/00", - pad: true, - }); +export const formatCnae = (value: string | number, options?: FormatCnaeOptions): string => { + if (isNullish(value)) return ""; + + return format({ + pad: options?.pad, + value: sanitizeToDigits(value), + pattern: "0000-0/00", + }); +}; diff --git a/src/format-cno/format-cno.test.ts b/src/format-cno/format-cno.test.ts index 7de89270..255ae9c7 100644 --- a/src/format-cno/format-cno.test.ts +++ b/src/format-cno/format-cno.test.ts @@ -69,6 +69,15 @@ describe("formatCno", () => { }); }); +describe("formatCno with a nullish value under pad", () => { + test("should return an empty string instead of a zero-filled document", () => { + // @ts-expect-error: intentionally invalid input + expect(formatCno(null, { pad: true })).toBe(""); + // @ts-expect-error: intentionally invalid input + expect(formatCno(undefined, { pad: true })).toBe(""); + }); +}); + describe("formatCno types", () => { test("should take a string or number, optional options, and return a string", () => { expectTypeOf(formatCno).parameter(0).toEqualTypeOf(); diff --git a/src/format-cnpj/format-cnpj.test.ts b/src/format-cnpj/format-cnpj.test.ts index 55e7b54a..ba2f2afa 100644 --- a/src/format-cnpj/format-cnpj.test.ts +++ b/src/format-cnpj/format-cnpj.test.ts @@ -140,6 +140,11 @@ describe("formatCnpj", () => { ); }); + it("should obfuscate on any truthy obfuscate value, the way pad is read", () => { + // @ts-expect-error: intentionally not a boolean + expect(formatCnpj("46843485000186", { obfuscate: 1 })).toBe("**.843.485/0001-**"); + }); + it("should behave exactly as without the option when obfuscate is false or absent", () => { expect(formatCnpj("46843485000186", { obfuscate: false })).toBe("46.843.485/0001-86"); expect(formatCnpj("46843485000186")).toBe("46.843.485/0001-86"); @@ -190,6 +195,15 @@ describe("formatCnpj", () => { }); }); +describe("formatCnpj with a nullish value under pad", () => { + test("should return an empty string instead of a zero-filled document", () => { + // @ts-expect-error: intentionally invalid input + expect(formatCnpj(null, { pad: true })).toBe(""); + // @ts-expect-error: intentionally invalid input + expect(formatCnpj(undefined, { pad: true })).toBe(""); + }); +}); + describe("formatCnpj types", () => { test("should take a string or number value and options and return a string", () => { expectTypeOf(formatCnpj).parameter(0).toEqualTypeOf(); diff --git a/src/format-cnpj/format-cnpj.ts b/src/format-cnpj/format-cnpj.ts index ba966cab..be34e327 100644 --- a/src/format-cnpj/format-cnpj.ts +++ b/src/format-cnpj/format-cnpj.ts @@ -10,7 +10,7 @@ export type FormatCnpjOptions = { pad?: boolean; /** Which CNPJ format to read: `1` numeric only, `2` alphanumeric (default: `1`). */ version?: 1 | 2; - /** Whether to hide the first 2 digits and the 2 check digits with `*` (default: `false`). */ + /** Whether to hide the first 2 digits and the 2 check digits with `*` (default: `false`, read for truthiness like `pad`). */ obfuscate?: boolean; }; @@ -29,7 +29,8 @@ const sanitize = (value: string | number, version?: FormatCnpjOptions["version"] * @param {FormatCnpjOptions} [options] - Optional configuration for formatting the CNPJ. * @param {boolean} options.pad - If true, the value will be padded with leading zeros if necessary. * @param {1|2} options.version - The version of the CNPJ to be sanitized. - * @param {boolean} options.obfuscate - If true, hides the first 2 digits and the 2 check digits. + * @param {boolean} options.obfuscate - If truthy, hides the first 2 digits and the 2 check + * digits. Read for truthiness, the way `pad` is, so a non-boolean such as `1` obfuscates too. * @returns {string} The formatted CNPJ string in the pattern "00.000.000/0000-00". * * @example @@ -52,6 +53,6 @@ export const formatCnpj = (value: string | number, options?: FormatCnpjOptions): return format({ pad: options?.pad, value: sanitize(value, options?.version), - pattern: options?.obfuscate === true ? OBFUSCATED_PATTERN : PATTERN, + pattern: (options?.obfuscate ?? false) ? OBFUSCATED_PATTERN : PATTERN, }); }; diff --git a/src/format-cns/format-cns.test.ts b/src/format-cns/format-cns.test.ts index 687cbbef..e26280fd 100644 --- a/src/format-cns/format-cns.test.ts +++ b/src/format-cns/format-cns.test.ts @@ -13,6 +13,11 @@ describe("formatCns", () => { expect(formatCns("123456789010001")).toBe("123 4567 8901 0001"); }); + it("should round trip 898 0000 0004 3208, the only concrete CNS the ANVISA page prints", () => { + expect(formatCns("898000000043208")).toBe("898 0000 0004 3208"); + expect(formatCns("898 0000 0004 3208")).toBe("898 0000 0004 3208"); + }); + it("should format a number CNS with the space mask", () => { expect(formatCns(123_456_789_010_001)).toBe("123 4567 8901 0001"); }); diff --git a/src/format-cns/format-cns.ts b/src/format-cns/format-cns.ts index deea847b..48931c16 100644 --- a/src/format-cns/format-cns.ts +++ b/src/format-cns/format-cns.ts @@ -19,14 +19,19 @@ export type FormatCnsOptions = { * * @example * ```typescript - * formatCns("123456789010001"); // "123 4567 8901 0001" - * formatCns(123456789010001); // "123 4567 8901 0001" + * formatCns("123456789010000"); // "123 4567 8901 0000" + * formatCns(123456789010000); // "123 4567 8901 0000" * formatCns("89010001", { pad: true }); // "000 0000 8901 0001" * ``` * * @see Official: https://rni-docs.anvisa.gov.br/docs/regras_gerais/validacoes/validacaoCNS/ + * ANVISA's two validation routines, the ones implemented here. The page sits behind a bot filter + * and answers HTTP 403 to every non-browser client, so it has to be opened in a browser. * @see Based on: https://integracao.esusab.ufsc.br/ledi/documentacao/regras/algoritmo_CNS.html - * e-SUS APS documentation of the same DATASUS algorithm, reachable without a browser. + * e-SUS APS documentation of the same DATASUS algorithm, reachable without a browser. It applies + * the provisional routine to numbers starting with 5, 7, 8 or 9; this implementation follows the + * ANVISA page, which restricts it to 7, 8 and 9, so a 5 prefixed number is rejected even when its + * weighted sum checks out. */ export const formatCns = (value: string | number, options?: FormatCnsOptions): string => isNullish(value) diff --git a/src/format-cpf/format-cpf.test.ts b/src/format-cpf/format-cpf.test.ts index ea39c628..573950ef 100644 --- a/src/format-cpf/format-cpf.test.ts +++ b/src/format-cpf/format-cpf.test.ts @@ -99,6 +99,11 @@ describe("formatCpf", () => { expect(formatCpf("9438", { obfuscate: true })).toBe("***.8"); }); + it("should obfuscate on any truthy obfuscate value, the way pad is read", () => { + // @ts-expect-error: intentionally not a boolean + expect(formatCpf("94389575104", { obfuscate: 1 })).toBe("***.895.751-**"); + }); + it("should behave exactly as without the option when obfuscate is false or absent", () => { expect(formatCpf("94389575104", { obfuscate: false })).toBe("943.895.751-04"); expect(formatCpf("94389575104")).toBe("943.895.751-04"); @@ -134,6 +139,15 @@ describe("formatCpf", () => { }); }); +describe("formatCpf with a nullish value under pad", () => { + test("should return an empty string instead of a zero-filled document", () => { + // @ts-expect-error: intentionally invalid input + expect(formatCpf(null, { pad: true })).toBe(""); + // @ts-expect-error: intentionally invalid input + expect(formatCpf(undefined, { pad: true })).toBe(""); + }); +}); + describe("formatCpf types", () => { test("should take a string or number value and options and return a string", () => { expectTypeOf(formatCpf).parameter(0).toEqualTypeOf(); diff --git a/src/format-cpf/format-cpf.ts b/src/format-cpf/format-cpf.ts index d949c01a..31faa6b0 100644 --- a/src/format-cpf/format-cpf.ts +++ b/src/format-cpf/format-cpf.ts @@ -7,7 +7,7 @@ import { OBFUSCATED_PATTERN, PATTERN } from "./constants"; export type FormatCpfOptions = { /** Whether to left pad the value with zeros up to the number of slots in the pattern (default: `false`). */ pad?: boolean; - /** Whether to hide the first 3 digits and the 2 check digits with `*` (default: `false`). */ + /** Whether to hide the first 3 digits and the 2 check digits with `*` (default: `false`, read for truthiness like `pad`). */ obfuscate?: boolean; }; @@ -17,7 +17,8 @@ export type FormatCpfOptions = { * @param {string|number} value - The CPF value to be formatted. It can be a string or a number. * @param {FormatCpfOptions} [options] - Optional formatting options. * @param {boolean} options.pad - If true, the value will be padded with leading zeros if necessary. - * @param {boolean} options.obfuscate - If true, hides the first 3 digits and the 2 check digits. + * @param {boolean} options.obfuscate - If truthy, hides the first 3 digits and the 2 check + * digits. Read for truthiness, the way `pad` is, so a non-boolean such as `1` obfuscates too. * @returns {string} The formatted CPF string in the pattern "000.000.000-00". * * @example @@ -29,7 +30,6 @@ export type FormatCpfOptions = { * ``` * * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/meu-cpf - * @see Official: http://sped.rfb.gov.br/arquivo/show/8231 * @see Based on: https://github.com/brazilian-utils/python/blob/main/brutils/cpf.py */ export const formatCpf = (value: string | number, options?: FormatCpfOptions): string => { @@ -38,6 +38,6 @@ export const formatCpf = (value: string | number, options?: FormatCpfOptions): s return format({ pad: options?.pad, value: sanitizeToDigits(value), - pattern: options?.obfuscate === true ? OBFUSCATED_PATTERN : PATTERN, + pattern: (options?.obfuscate ?? false) ? OBFUSCATED_PATTERN : PATTERN, }); }; diff --git a/src/format-currency/format-currency.test.ts b/src/format-currency/format-currency.test.ts index a96880fc..c6f7945e 100644 --- a/src/format-currency/format-currency.test.ts +++ b/src/format-currency/format-currency.test.ts @@ -119,6 +119,35 @@ describe("formatCurrency", () => { expect(formatCurrency()).toBe(""); }); + it("should return an empty string for a value with no numeric reading", () => { + expect(formatCurrency(Object.create(null))).toBe(""); + // @ts-expect-error: intentionally invalid input + expect(formatCurrency(Symbol("x"))).toBe(""); + // @ts-expect-error: intentionally invalid input + expect(formatCurrency({})).toBe(""); + }); + + it("should coerce the other values the way 2.3.0 did", () => { + // @ts-expect-error: intentionally invalid input + expect(formatCurrency(null)).toBe("0,00"); + // @ts-expect-error: intentionally invalid input + expect(formatCurrency([])).toBe("0,00"); + // @ts-expect-error: intentionally invalid input + expect(formatCurrency(true)).toBe("1,00"); + }); + + it("should read a bigint as a whole number", () => { + // @ts-expect-error: intentionally invalid input + expect(formatCurrency(1234n)).toBe("1.234,00"); + }); + + it("should fall back to a precision of 2 when the requested one is not a finite number", () => { + // @ts-expect-error: intentionally invalid input + expect(formatCurrency(1234.5678, { precision: "3" })).toBe("1.234,57"); + // @ts-expect-error: intentionally invalid input + expect(formatCurrency(1234.5678, { precision: true })).toBe("1.234,57"); + }); + it("should read as many fraction digits as the requested precision allows, not just the default 2, when reading a string", () => { expect(formatCurrency("1234,12345", { precision: 5 })).toBe("1.234,12345"); }); @@ -129,20 +158,32 @@ describe("formatCurrency", () => { }); describe("properties", () => { - const optionsArbitrary = fc - .option(fc.record({ symbol: fc.boolean(), precision: fc.double() }, { requiredKeys: [] })) - .map((options) => options ?? undefined); + const hostileValues = fc.oneof( + anyGarbage, + fc.constant(Object.create(null)), + fc.constant(Symbol("x")), + fc.bigInt(), + ); + + const nulls = fc.constant(null); + const symbols = fc.oneof(fc.boolean(), nulls, fc.string()); + const precisions = fc.oneof(fc.double(), nulls, fc.string(), fc.boolean()); + const optionRecord = fc.record( + { symbol: symbols, precision: precisions }, + { requiredKeys: [] }, + ); + const optionsArbitrary = fc.option(optionRecord).map((options) => options ?? undefined); test("should round-trip with parseCurrency for any value with 2 decimals", () => { expectRoundTrip(formatCurrency, parseCurrency, twoDecimalAmounts); }); test("should never throw, regardless of the input", () => { - expectNeverThrowsWithOptions(formatCurrency, anyGarbage, optionsArbitrary); + expectNeverThrowsWithOptions(formatCurrency, hostileValues, optionsArbitrary); }); test("should always return a string", () => { - expectAlwaysReturnsType(formatCurrency, "string", anyGarbage); + expectAlwaysReturnsType(formatCurrency, "string", hostileValues); }); }); }); diff --git a/src/format-currency/format-currency.ts b/src/format-currency/format-currency.ts index 01fd616b..0d0193d5 100644 --- a/src/format-currency/format-currency.ts +++ b/src/format-currency/format-currency.ts @@ -34,10 +34,13 @@ const getFormatter = (symbol: boolean, precision: number): Intl.NumberFormat => return formatter; }; -const toNumber = (value: unknown, precision: number): number => - typeof value === "string" - ? parseDecimal(value, { maxFractionDigits: Math.max(DEFAULT_PRECISION, precision) }) - : Number(value); +const toNumber = (value: unknown, precision: number): number => { + if (typeof value === "string") { + return parseDecimal(value, { maxFractionDigits: Math.max(DEFAULT_PRECISION, precision) }); + } + + return Number(value); +}; /** * Formats a given value as a currency string in Brazilian Real (BRL). @@ -50,9 +53,13 @@ const toNumber = (value: unknown, precision: number): number => * `"1.234,00"`. * * A value that is not a finite number, such as `NaN`, `Infinity` or `-Infinity`, formats as - * an empty string. + * an empty string, and so does a value that cannot be coerced to a number at all, such as a + * symbol, a null-prototype object or a plain object (`Number({})` is `NaN`); every other + * value goes through `Number()` the way 2.3.0 did, so `null`, `[]` and `true` still format. * - * The precision is clamped to `0-20`, the range Node's `Intl.NumberFormat` accepts. + * The precision is clamped to `0-20`, the package limit, the bound Node 20 still enforces on + * `Intl.NumberFormat` (ES2023 raised it to 100, and newer runtimes accept more), and a + * precision that is not a finite number falls back to 2. * * @param {string|number} value - The value to be formatted. Can be a string or a number. * @param {FormatCurrencyOptions} [options] - Optional formatting options. @@ -60,6 +67,13 @@ const toNumber = (value: unknown, precision: number): number => * @param {number} options.precision - The number of decimal places to include in the formatted string. Defaults to 2, clamped to 0-20. * @returns {string} The formatted currency string, or an empty string when the value is not finite. * + * The `R$` prefix and the comma before the centavos are the ones Lei nº 9.069/1995, art. 1º, + * §§ 1º and 2º prescribes; the `.` grouping comes from the CLDR pt-BR locale data behind + * `Intl.NumberFormat`. + * + * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l9069.htm + * @see Based on: https://cldr.unicode.org/ + * * @example * ```typescript * formatCurrency(1234.56); // "1.234,56" @@ -73,11 +87,15 @@ const toNumber = (value: unknown, precision: number): number => export const formatCurrency = (value: string | number, options?: FormatCurrencyOptions): string => { const precision = clampPrecision(options?.precision); - const enhancedValue = toNumber(value, precision); + try { + const enhancedValue = toNumber(value, precision); - if (!Number.isFinite(enhancedValue)) return ""; + if (!Number.isFinite(enhancedValue)) return ""; - return getFormatter(Boolean(options?.symbol), precision) - .format(enhancedValue) - .replace("\u00A0", " "); + return getFormatter(Boolean(options?.symbol), precision) + .format(enhancedValue) + .replaceAll("\u00A0", " "); + } catch { + return ""; + } }; diff --git a/src/format-iban/format-iban.test.ts b/src/format-iban/format-iban.test.ts index c64d8fc6..d584a453 100644 --- a/src/format-iban/format-iban.test.ts +++ b/src/format-iban/format-iban.test.ts @@ -38,10 +38,14 @@ describe("formatIban", () => { ); }); - it("should return an empty string when a character outside the print format is present", () => { - expect(formatIban("BR1500000000000010932840814P-2")).toBe(""); - expect(formatIban("BR15 0000-0000.0000/1093 2840 814P 2")).toBe(""); - expect(formatIban("BR15 0000")).toBe(""); + it("should read only the letters and digits of a value with other characters, like formatCpf", () => { + expect(formatIban("BR1500000000000010932840814P-2")).toBe( + "BR15 0000 0000 0000 1093 2840 814P 2", + ); + expect(formatIban("BR15 0000-0000.0000/1093 2840 814P 2")).toBe( + "BR15 0000 0000 0000 1093 2840 814P 2", + ); + expect(formatIban("BR15 0000")).toBe("BR15 0000"); }); it("should cap the result to 29 characters", () => { diff --git a/src/format-iban/format-iban.ts b/src/format-iban/format-iban.ts index 2a4b6d70..c41fedec 100644 --- a/src/format-iban/format-iban.ts +++ b/src/format-iban/format-iban.ts @@ -1,4 +1,4 @@ -import { BR_IBAN_LENGTH, IBAN_FORMAT_REGEX } from "../_internals/constants/iban"; +import { BR_IBAN_LENGTH } from "../_internals/constants/iban"; import { sanitizeToAlphanumeric } from "../_internals/sanitize-to-alphanumeric/sanitize-to-alphanumeric"; import { GROUP_SIZE } from "./constants"; @@ -12,15 +12,14 @@ import { GROUP_SIZE } from "./constants"; * length. Use `isValidIban` to check validity. * * The value may be compact (`"BR1500000000000010932840814P2"`), already in the ISO 13616 print - * format (letters and digits in groups separated by a single space) or a partial value still - * being typed, in every case with optional surrounding whitespace. Only a character outside - * letters and digits, or a separator other than a single space, makes the value something - * other than an IBAN, and then the function returns an empty string instead of quietly - * dropping the character and presenting the rest as an IBAN. + * format, or a partial value still being typed. Like every formatter of this package, it is read + * for its letters and digits and grouped as far as they go: any other character (a hyphen, a + * dot, extra whitespace) is dropped and the letters are uppercased. Only a value that is not a + * string gives an empty string. * * @param {string} value - The IBAN to be formatted. * @returns {string} The IBAN uppercased and grouped in blocks of 4 characters, or an empty - * string when `value` is not a string written in the print format. + * string when `value` is not a string. * * @example * ```typescript @@ -28,20 +27,18 @@ import { GROUP_SIZE } from "./constants"; * formatIban("br1500000000000010932840814p2"); // "BR15 0000 0000 0000 1093 2840 814P 2" * formatIban("BR15"); // "BR15" * formatIban("BR1500000000000010932840814P2EXTRA"); // "BR15 0000 0000 0000 1093 2840 814P 2" - * formatIban("BR1500000000000010932840814P-2"); // "" (hyphens are not part of an IBAN) + * formatIban("BR15 0000-0000.0000/1093 2840 814P-2"); // "BR15 0000 0000 0000 1093 2840 814P 2" * ``` * - * @see Official: https://www.bcb.gov.br/pre/normativos/circ/2013/pdf/circ_3625_v1_O.pdf Circular BCB nº 3.625/2013 - * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/Documents/sistema_pagamentos_brasileiro/IBAN-Guidelines_%20port.pdf Diretrizes de Implementação do IBAN no Brasil + * @see Official: https://www.bcb.gov.br/pre/normativos/circ/2013/pdf/circ_3625_v1_O.pdf + * Circular BCB nº 3.625/2013 + * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/Documents/sistema_pagamentos_brasileiro/IBAN-Guidelines_%20port.pdf + * Diretrizes de Implementação do IBAN no Brasil */ export const formatIban = (value: string): string => { if (typeof value !== "string") return ""; - const printed = value.trim(); - - if (!IBAN_FORMAT_REGEX.test(printed)) return ""; - - const sanitized = sanitizeToAlphanumeric(printed).slice(0, BR_IBAN_LENGTH); + const sanitized = sanitizeToAlphanumeric(value).slice(0, BR_IBAN_LENGTH); let formatted = ""; diff --git a/src/format-legal-nature/format-legal-nature.test.ts b/src/format-legal-nature/format-legal-nature.test.ts index d13ed4e2..929c8680 100644 --- a/src/format-legal-nature/format-legal-nature.test.ts +++ b/src/format-legal-nature/format-legal-nature.test.ts @@ -6,7 +6,7 @@ import { } from "../_internals/test/properties"; import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; import { parseLegalNature } from "../parse-legal-nature/parse-legal-nature"; -import { formatLegalNature } from "./format-legal-nature"; +import { type FormatLegalNatureOptions, formatLegalNature } from "./format-legal-nature"; describe("formatLegalNature", () => { it("should format legal nature values", () => { @@ -17,6 +17,19 @@ describe("formatLegalNature", () => { expect(formatLegalNature("2062")).toBe("206-2"); }); + it("should format a number and read only the digits of a masked value, like formatCpf", () => { + expect(formatLegalNature(2062)).toBe("206-2"); + expect(formatLegalNature("206-2")).toBe("206-2"); + expect(formatLegalNature("abc2062")).toBe("206-2"); + }); + + it("should left pad with zeros to 4 digits when pad is true", () => { + expect(formatLegalNature("62", { pad: true })).toBe("006-2"); + expect(formatLegalNature(62, { pad: true })).toBe("006-2"); + expect(formatLegalNature("2062", { pad: true })).toBe("206-2"); + expect(formatLegalNature("62", { pad: false })).toBe("62"); + }); + it("should return an empty string for null or undefined", () => { // @ts-expect-error: intentionally invalid input expect(formatLegalNature(null)).toBe(""); @@ -44,6 +57,9 @@ describe("formatLegalNature", () => { describe("formatLegalNature types", () => { test("should take a string or number value and return a string", () => { expectTypeOf(formatLegalNature).parameter(0).toEqualTypeOf(); + expectTypeOf(formatLegalNature) + .parameter(1) + .toEqualTypeOf(); expectTypeOf(formatLegalNature).returns.toEqualTypeOf(); }); }); diff --git a/src/format-legal-nature/format-legal-nature.ts b/src/format-legal-nature/format-legal-nature.ts index 8283f388..fd072811 100644 --- a/src/format-legal-nature/format-legal-nature.ts +++ b/src/format-legal-nature/format-legal-nature.ts @@ -2,24 +2,47 @@ import { format } from "../_internals/format/format"; import { isNullish } from "../_internals/is-nullish/is-nullish"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +/** Options of `formatLegalNature`. */ +export type FormatLegalNatureOptions = { + /** Whether to left pad the value with zeros up to the 4 digits of a complete code (default: `false`). */ + pad?: boolean; +}; + /** * Formats a Brazilian legal nature (natureza jurídica) code. * + * Like every formatter of this package, the value is read for its digits and masked as far as + * they go (`"206"` stays `"206"`, `"2062"` becomes `"206-2"`); with `pad: true` it is first left + * padded with zeros to the 4 digits of a complete code. Use `isValidLegalNature` to check a code. + * * @param {string|number} value - The legal nature code to be formatted. + * @param {FormatLegalNatureOptions} [options] - Optional formatting options. + * @param {boolean} [options.pad] - Whether to pad the value with leading zeros. Defaults to `false`. * @returns {string} The formatted code, or an empty string when there is nothing to format. * * @example * ```typescript * formatLegalNature("2062"); // "206-2" + * formatLegalNature(2062); // "206-2" + * formatLegalNature("206"); // "206" (partial values are masked as far as they go) + * formatLegalNature("62", { pad: true }); // "006-2" * ``` * + * The CONCLA table page sits behind a bot filter and answers HTTP 403 to every non-browser + * client, so it has to be opened in a browser; the detailed structure PDF next to it is served + * normally. + * * @see Official: https://concla.ibge.gov.br/estrutura/natjur-estrutura/natureza-juridica-2021 * @see Official: https://concla.ibge.gov.br/images/concla/documentacao/CONCLA-TNJ2021-EstruturaDetalhada.pdf */ -export const formatLegalNature = (value: string | number): string => +export const formatLegalNature = ( + value: string | number, + options?: FormatLegalNatureOptions, +): string => isNullish(value) ? "" : format({ + pad: options?.pad, value: sanitizeToDigits(value), pattern: "000-0", }); diff --git a/src/format-license-plate/format-license-plate.ts b/src/format-license-plate/format-license-plate.ts index a425a751..a2045eb9 100644 --- a/src/format-license-plate/format-license-plate.ts +++ b/src/format-license-plate/format-license-plate.ts @@ -19,7 +19,12 @@ import { OLD_FORMAT_SEPARATOR_INDEX } from "./constants"; * formatLicensePlate("1234567"); // "" * ``` * + * The `AAA-1111` shape of the old PNU is art. 2º § 3º of Resolução CONTRAN nº 969/2022; the + * separatorless `LLLNLNN` shape of the Mercosul plate is item 1.2 of its Anexo I, published in a + * PDF of its own. Both are cited below. + * * @see Official: https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes/resolucao9692022.pdf + * @see Official: https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes/resolucao9692022anexos.pdf */ export const formatLicensePlate = (value: string): string => { const parsed = parseLicensePlate(value); diff --git a/src/format-ncm/format-ncm.test.ts b/src/format-ncm/format-ncm.test.ts index 9426e236..47fcbf02 100644 --- a/src/format-ncm/format-ncm.test.ts +++ b/src/format-ncm/format-ncm.test.ts @@ -1,11 +1,11 @@ -import { anyGarbage, digits } from "../_internals/test/arbitraries"; +import { anyGarbage, digits, digitsUpTo } from "../_internals/test/arbitraries"; import { expectIdempotent, expectMatchesPattern, expectNeverThrows, } from "../_internals/test/properties"; import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; -import { formatNcm } from "./format-ncm"; +import { formatNcm, type FormatNcmOptions } from "./format-ncm"; describe("formatNcm", () => { it("should format an NCM code given as digits", () => { @@ -20,7 +20,7 @@ describe("formatNcm", () => { expect(formatNcm("8471.30.12")).toBe("8471.30.12"); }); - it("should format a partial value progressively", () => { + it("should mask a partial value progressively by default", () => { expect(formatNcm("8")).toBe("8"); expect(formatNcm("84")).toBe("84"); expect(formatNcm("847")).toBe("847"); @@ -30,6 +30,11 @@ describe("formatNcm", () => { expect(formatNcm("8471301")).toBe("8471.30.1"); }); + it("should mask a partial number progressively by default", () => { + expect(formatNcm(8471)).toBe("8471"); + expect(formatNcm(847_130)).toBe("8471.30"); + }); + it("should not validate whether the code exists in the official table", () => { expect(formatNcm("00000000")).toBe("0000.00.00"); }); @@ -38,6 +43,29 @@ describe("formatNcm", () => { expect(formatNcm("")).toBe(""); }); + it("should not add digits after the NCM length", () => { + expect(formatNcm("847130120000")).toBe("8471.30.12"); + }); + + describe("pad option", () => { + it("should left pad a short code with zeros up to the full NCM length", () => { + expect(formatNcm("", { pad: true })).toBe("0000.00.00"); + expect(formatNcm("1", { pad: true })).toBe("0000.00.01"); + expect(formatNcm("8471", { pad: true })).toBe("0000.84.71"); + expect(formatNcm("847130", { pad: true })).toBe("0084.71.30"); + expect(formatNcm("84713012", { pad: true })).toBe("8471.30.12"); + }); + + it("should left pad a number the same way as its digits", () => { + expect(formatNcm(8471, { pad: true })).toBe("0000.84.71"); + expect(formatNcm(84_713_012, { pad: true })).toBe("8471.30.12"); + }); + + it("should mask progressively for an explicit false", () => { + expect(formatNcm("8471", { pad: false })).toBe("8471"); + }); + }); + it("should return an empty string for null and undefined", () => { // @ts-expect-error not a string or number expect(formatNcm(null)).toBe(""); @@ -45,6 +73,28 @@ describe("formatNcm", () => { expect(formatNcm()).toBe(""); }); + it("should return an empty string for null and undefined even under pad, instead of a zero-filled code", () => { + // @ts-expect-error not a string or number + expect(formatNcm(null, { pad: true })).toBe(""); + // @ts-expect-error not a string or number + expect(formatNcm(undefined, { pad: true })).toBe(""); + }); + + it("should read only the digits of a value with other characters, like formatCpf", () => { + expect(formatNcm("abc8471")).toBe("8471"); + expect(formatNcm("8471.30-12")).toBe("8471.30.12"); + }); + + it("should read a signed or fractional number as the string of its digits, like formatCpf", () => { + expect(formatNcm(-84_713_012)).toBe("8471.30.12"); + expect(formatNcm(8_471_301.2)).toBe("8471.30.12"); + expect(formatNcm(2 ** 53)).toBe("9007.19.92"); + }); + + it("should return an empty string for a null-prototype object", () => { + expect(formatNcm(Object.create(null))).toBe(""); + }); + describe("properties", () => { const eightDigitArbitrary = digits(8); @@ -56,6 +106,14 @@ describe("formatNcm", () => { expectMatchesPattern(formatNcm, /^\d{4}\.\d{2}\.\d{2}$/, eightDigitArbitrary); }); + test("should format every shorter value in the NNNN.NN.NN pattern when padding", () => { + expectMatchesPattern( + (value) => formatNcm(value, { pad: true }), + /^\d{4}\.\d{2}\.\d{2}$/, + digitsUpTo(8), + ); + }); + test("should be idempotent on a full 8 digit code", () => { expectIdempotent(formatNcm, eightDigitArbitrary); }); @@ -63,8 +121,13 @@ describe("formatNcm", () => { }); describe("formatNcm types", () => { - test("should take a string or number and return a string", () => { + test("should take a string or number value and options and return a string", () => { expectTypeOf(formatNcm).parameter(0).toEqualTypeOf(); + expectTypeOf(formatNcm).parameter(1).toEqualTypeOf(); expectTypeOf(formatNcm).returns.toEqualTypeOf(); }); + + test("should type the pad option as an optional boolean", () => { + expectTypeOf().toEqualTypeOf(); + }); }); diff --git a/src/format-ncm/format-ncm.ts b/src/format-ncm/format-ncm.ts index c9076b2e..c78ccbd0 100644 --- a/src/format-ncm/format-ncm.ts +++ b/src/format-ncm/format-ncm.ts @@ -2,13 +2,35 @@ import { format } from "../_internals/format/format"; import { isNullish } from "../_internals/is-nullish/is-nullish"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +/** Options of `formatNcm`. */ +export type FormatNcmOptions = { + /** Whether to left pad the value with zeros up to the 8 digits of a complete NCM code (default: `false`). */ + pad?: boolean; +}; + /** * Formats a NCM (Nomenclatura Comum do Mercosul) code. * * This is a purely structural transformation, it does not check the code against the * official table, use `isValidNcm` for that. * + * With the default `pad: false` the mask is applied progressively, as far as the value goes, + * which is what an input being typed into needs (`"8471"` stays `"8471"`, `"847130"` becomes + * `"8471.30"`). With `pad: true` the value is first left padded with zeros to the 8 digits of a + * complete code, so it always comes back fully masked (`"8471"` gives `"0000.84.71"`). + * A number is treated exactly like the string of its digits: it is only padded under + * `pad: true`, so `formatNcm(8471)` gives `"8471"` and `formatNcm(8471, { pad: true })` gives + * `"0000.84.71"`. + * + * Like every formatter of this package, the value is read for its digits and masked as far as + * they go: characters outside the mask are dropped (`formatNcm("abc8471")` gives + * `"8471"`) and a number is read as the string of its digits, sign and decimal point + * included (`formatNcm(-84713012)` gives `"8471.30.12"`). This is the input-mask contract of + * `formatCpf`; use `isValidNcm` to check a code. + * * @param {string|number} value - The NCM code to be formatted. + * @param {FormatNcmOptions} [options] - Optional formatting options. + * @param {boolean} [options.pad] - Whether to pad the value with leading zeros. Defaults to `false`. * @returns {string} The formatted code in the `NNNN.NN.NN` pattern, or an empty string * when there is nothing to format. * @@ -16,15 +38,21 @@ import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-d * ```typescript * formatNcm("84713012"); // "8471.30.12" * formatNcm(84713012); // "8471.30.12" - * formatNcm("8471"); // "8471" (partial values are formatted progressively) + * formatNcm("8471"); // "8471" (partial values are masked as far as they go) + * formatNcm("847130"); // "8471.30" + * formatNcm("8471", { pad: true }); // "0000.84.71" (padded to 8 digits first) + * formatNcm("abc8471"); // "8471" (only the digits are read) + * formatNcm(-84713012); // "8471.30.12" * ``` * * @see Official: https://portalunico.siscomex.gov.br/classif/api/publico/nomenclatura/download/json */ -export const formatNcm = (value: string | number): string => - isNullish(value) - ? "" - : format({ - value: sanitizeToDigits(value), - pattern: "0000.00.00", - }); +export const formatNcm = (value: string | number, options?: FormatNcmOptions): string => { + if (isNullish(value)) return ""; + + return format({ + pad: options?.pad, + value: sanitizeToDigits(value), + pattern: "0000.00.00", + }); +}; diff --git a/src/format-nfe-key/format-nfe-key.test.ts b/src/format-nfe-key/format-nfe-key.test.ts index 68ae4622..87e57578 100644 --- a/src/format-nfe-key/format-nfe-key.test.ts +++ b/src/format-nfe-key/format-nfe-key.test.ts @@ -1,7 +1,9 @@ import * as fc from "fast-check"; +import { anyGarbage } from "../_internals/test/arbitraries"; +import { expectNeverThrows, expectPadsToLength } from "../_internals/test/properties"; import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; -import { formatNfeKey } from "./format-nfe-key"; +import { formatNfeKey, type FormatNfeKeyOptions } from "./format-nfe-key"; const KEY = "35170458716523000119550010000000121000123458"; const FORMATTED = "3517 0458 7165 2300 0119 5500 1000 0000 1210 0012 3458"; @@ -23,6 +25,31 @@ describe("formatNfeKey", () => { expect(formatNfeKey(`${KEY}999999`)).toBe(FORMATTED); }); + describe("should left pad the value", () => { + test("when options.pad is true", () => { + expect(formatNfeKey("12345", { pad: true })).toBe( + "0000 0000 0000 0000 0000 0000 0000 0000 0000 0001 2345", + ); + }); + + test("keeping a complete access key untouched", () => { + expect(formatNfeKey(KEY, { pad: true })).toBe(FORMATTED); + }); + + test("and nothing else when options.pad is false, undefined or the options object is missing", () => { + expect(formatNfeKey("12345", { pad: false })).toBe("1234 5"); + expect(formatNfeKey("12345", {})).toBe("1234 5"); + expect(formatNfeKey("12345")).toBe("1234 5"); + }); + + test("without throwing when the options object is not one", () => { + // @ts-expect-error: intentionally invalid input + expect(formatNfeKey("12345", null)).toBe("1234 5"); + // @ts-expect-error: intentionally invalid input + expect(formatNfeKey("12345", "pad")).toBe("1234 5"); + }); + }); + test("should remove all non numeric characters, including the NFe prefix", () => { expect(formatNfeKey(`NFe${KEY}`)).toBe(FORMATTED); expect(formatNfeKey(FORMATTED)).toBe(FORMATTED); @@ -44,6 +71,14 @@ describe("formatNfeKey", () => { expect(formatNfeKey([])).toBe(""); // @ts-expect-error: intentionally invalid input expect(formatNfeKey(true)).toBe(""); + // @ts-expect-error: intentionally invalid input + expect(formatNfeKey(-11)).toBe("11"); + // @ts-expect-error: intentionally invalid input + expect(formatNfeKey(1.1)).toBe("11"); + }); + + test("should return an empty string for an object with a null prototype, which has no toString", () => { + expect(formatNfeKey(Object.create(null))).toBe(""); }); describe("properties", () => { @@ -80,12 +115,27 @@ describe("formatNfeKey", () => { ), ); }); + + test("should left pad a shorter value up to the access key length", () => { + expectPadsToLength( + formatNfeKey, + (value) => value.replaceAll(/\D/g, ""), + fc.stringMatching(/^[0-9]{0,44}$/), + 44, + ); + }); + + test("should never throw for any garbage input", () => { + expectNeverThrows(formatNfeKey, anyGarbage); + }); }); }); describe("formatNfeKey types", () => { - test("should take a string and return a string", () => { + test("should take a string, optional options, and return a string", () => { expectTypeOf(formatNfeKey).parameter(0).toEqualTypeOf(); + expectTypeOf(formatNfeKey).parameter(1).toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); expectTypeOf(formatNfeKey).returns.toEqualTypeOf(); }); }); diff --git a/src/format-nfe-key/format-nfe-key.ts b/src/format-nfe-key/format-nfe-key.ts index bc25ee87..ade8d888 100644 --- a/src/format-nfe-key/format-nfe-key.ts +++ b/src/format-nfe-key/format-nfe-key.ts @@ -3,23 +3,51 @@ import { isNullish } from "../_internals/is-nullish/is-nullish"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; import { PATTERN } from "./constants"; +/** Options of `formatNfeKey`. */ +export type FormatNfeKeyOptions = { + /** Whether to left pad the value with zeros up to the 44 digits of a complete access key (default: `false`). */ + pad?: boolean; +}; + /** * Formats a DF-e (Documento Fiscal eletrônico) access key (chave de acesso) into groups of 4 * digits separated by spaces, the form every auxiliary document prints it in: the DANFE of the * NF-e and the NFC-e, the DACTE of the CT-e, the CT-e OS and the GTV-e, the DAMDFE of the * MDF-e, the DABPE of the BP-e, the DANF3E of the NF3e and the DANFE-COM of the NFCom. * + * Like every formatter of this package, the value is read for its digits and grouped as far as + * they go, so a masked or partial key still being typed is grouped progressively and anything + * without a digit (an object, `true`, an object with a null prototype) gives `""` instead of + * throwing. Use `isValidNfeKey` to check a key. + * + * With `pad: true` the value is first left padded with zeros to the 44 digits of a complete + * access key, so it always comes back fully grouped (`"12345"` gives + * `"0000 0000 0000 0000 0000 0000 0000 0000 0000 0001 2345"`). + * + * The parameter is typed as a string because the 44 digits of an access key are more than a + * JavaScript number can hold exactly. At runtime a number is read as the string of its digits, + * like in every formatter of this package. + * * @param {string} value - The access key value to be formatted. + * @param {FormatNfeKeyOptions} [options] - Optional formatting options. + * @param {boolean} [options.pad] - Whether to pad the value with leading zeros. Defaults to `false`. * @returns {string} The formatted access key, e.g. "3520 0612 3456 ...". * * @example * ```typescript * formatNfeKey("35170458716523000119550010000000121000123458"); * // "3517 0458 7165 2300 0119 5500 1000 0000 1210 0012 3458" + * + * formatNfeKey("12345"); // "1234 5" (partial values are grouped as far as they go) + * + * formatNfeKey("12345", { pad: true }); + * // "0000 0000 0000 0000 0000 0000 0000 0000 0000 0001 2345" * ``` * * @see Official: https://www.confaz.fazenda.gov.br/legislacao/arquivo-manuais/moc7-visao-geral.pdf * Manual de Orientação do Contribuinte (MOC) NF-e, "chave de acesso". */ -export const formatNfeKey = (value: string): string => - isNullish(value) ? "" : format({ value: sanitizeToDigits(value), pattern: PATTERN }); +export const formatNfeKey = (value: string, options?: FormatNfeKeyOptions): string => + isNullish(value) + ? "" + : format({ pad: options?.pad, value: sanitizeToDigits(value), pattern: PATTERN }); diff --git a/src/format-passport/format-passport.ts b/src/format-passport/format-passport.ts index 5fd38f11..df742b39 100644 --- a/src/format-passport/format-passport.ts +++ b/src/format-passport/format-passport.ts @@ -14,5 +14,6 @@ import { parsePassport } from "../parse-passport/parse-passport"; * formatPassport("") // "" * * @see Official: https://www.gov.br/pf/pt-br/assuntos/passaporte + * @see Official: https://www.gov.br/pf/pt-br/assuntos/passaporte/ajuda/duvidas_/caderneta/caderneta-numero-onde-fica-e */ export const formatPassport = (passport: string): string => parsePassport(passport); diff --git a/src/format-phone/format-phone.ts b/src/format-phone/format-phone.ts index d30e6e1d..15b95f09 100644 --- a/src/format-phone/format-phone.ts +++ b/src/format-phone/format-phone.ts @@ -5,7 +5,6 @@ import { SERVICE_PHONE_NON_GEOGRAPHIC_PREFIXES, } from "../_internals/constants/service-phone"; import { format } from "../_internals/format/format"; -import { isNullish } from "../_internals/is-nullish/is-nullish"; import { normalizePhone } from "../_internals/normalize-phone/normalize-phone"; import { resolveServicePhoneDigits } from "../_internals/resolve-service-phone-digits/resolve-service-phone-digits"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; @@ -138,8 +137,6 @@ const isPhoneMask = (value: unknown): value is PhoneMask => PHONE_MASKS.has(valu * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749 */ export const formatPhone = (value: string | number, options?: FormatPhoneOptions): string => { - if (isNullish(value)) return ""; - const enhancedValue = sanitizeToDigits(value); const serviceDigits = resolveServicePhoneDigits(value); diff --git a/src/format-voter-id/format-voter-id.test.ts b/src/format-voter-id/format-voter-id.test.ts index c766ef71..b7e6ae19 100644 --- a/src/format-voter-id/format-voter-id.test.ts +++ b/src/format-voter-id/format-voter-id.test.ts @@ -27,6 +27,13 @@ describe("formatVoterId", () => { expect(formatVoterId("1234567880299")).toBe("1234 5678 8 02 99"); }); + it("should drop the digits past the last slot of the pattern", () => { + expect(formatVoterId("1234567880191")).toBe("1234 5678 8 01 91"); + expect(formatVoterId("12345678801912")).toBe("1234 5678 8 01 91"); + expect(formatVoterId("123456788019123")).toBe("1234 5678 8 01 91"); + expect(formatVoterId("12345678803991")).toBe("1234 5678 80 39"); + }); + it("should keep the 12-digit grouping for a 13-digit value whose UF cannot carry 9 sequential digits", () => { expect(formatVoterId("1234567880399")).toBe("1234 5678 80 39"); }); diff --git a/src/format-voter-id/format-voter-id.ts b/src/format-voter-id/format-voter-id.ts index 69fa08a0..e6054adb 100644 --- a/src/format-voter-id/format-voter-id.ts +++ b/src/format-voter-id/format-voter-id.ts @@ -1,6 +1,5 @@ import { NINE_DIGIT_FEDERATIVE_UNION_CODES } from "../_internals/constants/voter-id"; import { format } from "../_internals/format/format"; -import { isNullish } from "../_internals/is-nullish/is-nullish"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; const PATTERN = "0000 0000 00 00"; @@ -28,14 +27,18 @@ const LENGTH = 12; * * The 13-digit São Paulo/Minas Gerais grouping is brutils parity, not published by the TSE. A * 14-or-more-digit input is read the same way as a 13-digit one: it is grouped as a São Paulo or - * Minas Gerais id whenever its 10th and 11th digits are "01"/"02", extra trailing digits included. + * Minas Gerais id whenever its 10th and 11th digits are "01"/"02". Both patterns have a fixed + * number of slots, 12 and 13, so anything past the last slot is dropped: + * `formatVoterId("12345678801912")` returns "1234 5678 8 01 91", the same string the 13-digit + * value "1234567880191" produces. + * + * The TSE resolution page sits behind a bot filter and answers HTTP 403 to every non-browser + * client, so it has to be opened in a browser. * * @see Official: https://www.tse.jus.br/legislacao/compilada/res/2021/resolucao-no-23-659-de-26-de-outubro-de-2021 * @see Based on: https://github.com/brazilian-utils/python/blob/main/brutils/voter_id.py */ export const formatVoterId = (value: string | number): string => { - if (isNullish(value)) return ""; - const digits = sanitizeToDigits(value); const federativeUnion = digits.slice(9, 11); const isExtended = diff --git a/src/generate-boleto/generate-boleto.test.ts b/src/generate-boleto/generate-boleto.test.ts index c1547af7..b733e921 100644 --- a/src/generate-boleto/generate-boleto.test.ts +++ b/src/generate-boleto/generate-boleto.test.ts @@ -12,6 +12,25 @@ import { type GenerateBoletoOptions, generateBoleto } from "./generate-boleto"; const drawArrecadacaoSegment = (): number => getBoletoInfo(generateBoleto({ type: "arrecadacao" }))?.segment ?? 0; +const drawArrecadacaoIdentifier = (algorithmDraw: number, valueDraw: number): string => { + const draws = [0, algorithmDraw, valueDraw]; + const originalRandom = Math.random; + let call = 0; + + try { + Math.random = (): number => { + const draw = draws[call] ?? 0; + call++; + + return draw; + }; + + return generateBoleto({ type: "arrecadacao" })[2]; + } finally { + Math.random = originalRandom; + } +}; + describe("generateBoleto", () => { test("should generate a valid boleto", () => { const boleto = generateBoleto(); @@ -93,18 +112,23 @@ describe("generateBoleto", () => { expect(segments.size).toBeGreaterThan(1); }); - test("should pick the value identifier (position 3) from the same Math.random() draw that selects the check digit algorithm, modulo 11 below 0.5 ('8') and modulo 10 at or above 0.5 ('6')", () => { - const originalRandom = Math.random; + test("should pick the value identifier (position 3) from all four values, the algorithm draw choosing modulo 11 ('8', '9') below 0.5 and modulo 10 ('6', '7') at or above it, and the value draw choosing an effective amount ('8', '6') below 0.5 and a reference quantity ('9', '7') at or above it", () => { + expect(drawArrecadacaoIdentifier(0.3, 0.3)).toBe("8"); + expect(drawArrecadacaoIdentifier(0.3, 0.5)).toBe("9"); + expect(drawArrecadacaoIdentifier(0.5, 0.3)).toBe("6"); + expect(drawArrecadacaoIdentifier(0.5, 0.5)).toBe("7"); + }); - try { - Math.random = () => 0.3; - expect(generateBoleto({ type: "arrecadacao" })[2]).toBe("8"); + test("should generate both an effective amount and a reference quantity across many draws", () => { + const flags = new Set( + Array.from( + { length: 200 }, + () => getBoletoInfo(generateBoleto({ type: "arrecadacao" }))?.hasEffectiveValue, + ), + ); - Math.random = () => 0.5; - expect(generateBoleto({ type: "arrecadacao" })[2]).toBe("6"); - } finally { - Math.random = originalRandom; - } + expect(flags.has(true)).toBe(true); + expect(flags.has(false)).toBe(true); }); }); @@ -141,7 +165,7 @@ describe("generateBoleto", () => { const value = generateBoleto({ type }); const info = getBoletoInfo(value); - expect(info).toBeDefined(); + expect(info).not.toBeNull(); expect(info?.bankCode).toBe(type === "arrecadacao" ? "" : value.slice(0, 3)); }), ); diff --git a/src/generate-boleto/generate-boleto.ts b/src/generate-boleto/generate-boleto.ts index 194042c5..4704233e 100644 --- a/src/generate-boleto/generate-boleto.ts +++ b/src/generate-boleto/generate-boleto.ts @@ -9,6 +9,11 @@ export type GenerateBoletoOptions = { type?: "bancario" | "arrecadacao"; }; +const ARRECADACAO_VALUE_IDENTIFIERS = { + mod10: { effective: "6", reference: "7" }, + mod11: { effective: "8", reference: "9" }, +}; + const generateBancario = (): string => { const p1Base = generateRandomNumber(9); const p2Base = generateRandomNumber(10); @@ -39,12 +44,17 @@ const generateBancario = (): string => { const generateArrecadacao = (): string => { const segment = ARRECADACAO_SEGMENTS[Math.floor(Math.random() * ARRECADACAO_SEGMENTS.length)]; const useMod11 = Math.random() < 0.5; + const hasEffectiveValue = Math.random() < 0.5; const checkDigit = useMod11 ? (value: string): number => mod11(value, { variant: "arrecadacao" }) : mod10; + const identifier = + ARRECADACAO_VALUE_IDENTIFIERS[useMod11 ? "mod11" : "mod10"][ + hasEffectiveValue ? "effective" : "reference" + ]; const body = generateRandomNumber(40); - const head = `${ARRECADACAO_PRODUCT}${segment}${useMod11 ? "8" : "6"}`; + const head = `${ARRECADACAO_PRODUCT}${segment}${identifier}`; const barcode = head + checkDigit(head + body) + body; let line = ""; @@ -62,6 +72,10 @@ const generateArrecadacao = (): string => { * * Uses `Math.random()` internally, so it is not cryptographically secure, do not use for security purposes. * + * An arrecadação slip draws its segment from 1 to 7 (segment 9 is the banks' own) and its value + * identifier from all four values, `6` and `8` for an effective amount and `7` and `9` for a + * reference quantity, so both `hasEffectiveValue` branches of `getBoletoInfo` are reachable. + * * @param {GenerateBoletoOptions} [options] - Optional options. * @param {string} options.type - `"bancario"` (default) or `"arrecadacao"`. * @returns {string} A valid 47-digit boleto string without formatting, or a 48-digit one for arrecadação. @@ -72,12 +86,14 @@ const generateArrecadacao = (): string => { * generateBoleto({ type: "arrecadacao" }); // "846100000005246100291102005460339004695895061080" * ``` * - * Carta-Circular BCB nº 2.926/2000 specifies the linha digitável fields, the módulo 11 check - * digit (using 1 for remainders 0, 10 and 1) and the fator de vencimento behind the 47 digit - * cobrança bancária slip; the FEBRABAN layout index covers the arrecadação slip. + * Carta-Circular BCB nº 2.926/2000 specifies the linha digitável fields and the módulo 11 + * check digit (using 1 for remainders 0, 10 and 1) of the 47 digit cobrança bancária slip, + * including the position of the fator de vencimento field. The FEBRABAN "Layout Padrão de + * Arrecadação/Recebimento com Utilização do Código de Barras" and the FEBRABAN layout index + * cover the arrecadação slip. * - * @see Official: https://cmsarquivos.febraban.org.br/Arquivos/documentos/PDF/Layout%20-%20C%C3%B3digo%20de%20Barras%20-%20Vers%C3%A3o%208%20-%2011_05_2026.pdf * @see Official: https://www.bcb.gov.br/pre/normativos/c_circ/2000/pdf/c_circ_2926_v1_O.pdf + * @see Official: https://cmsarquivos.febraban.org.br/Arquivos/documentos/PDF/Layout%20-%20C%C3%B3digo%20de%20Barras%20-%20Vers%C3%A3o%208%20-%2011_05_2026.pdf * @see Official: https://portal.febraban.org.br/pagina/3425/33/pt-br/layout-febraban */ export const generateBoleto = (options?: GenerateBoletoOptions): string => diff --git a/src/generate-cnpj/generate-cnpj.test.ts b/src/generate-cnpj/generate-cnpj.test.ts index c82a5782..54ba0d23 100644 --- a/src/generate-cnpj/generate-cnpj.test.ts +++ b/src/generate-cnpj/generate-cnpj.test.ts @@ -3,10 +3,20 @@ import * as fc from "fast-check"; import { CNPJ_LENGTH } from "../_internals/constants/cnpj"; import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; import { isValidCnpj } from "../is-valid-cnpj/is-valid-cnpj"; -import { generateCnpj } from "./generate-cnpj"; +import { type GenerateCnpjOptions, generateCnpj } from "./generate-cnpj"; const REMAINDER_TWO_DRAWS = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]; +const BRANCH_FALLBACK_DRAWS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2]; + +const INVALID_BRANCHES: [string, number][] = [ + ["0, below the first ordem", 0], + ["10000, past the last ordem", 10_000], + ["1.5, not an integer", 1.5], + ["-1, a negative ordem", -1], + ["NaN", Number.NaN], +]; + const generateWithForcedDraws = ( draws: number[], alphabetSize: number, @@ -159,6 +169,92 @@ describe("generateCnpj", () => { }); }); + describe("options object", () => { + test("should generate a numeric CNPJ for an empty options object", () => { + const cnpj = generateCnpj({}); + + expect(cnpj).toHaveLength(CNPJ_LENGTH); + expect(/^\d+$/.test(cnpj)).toBe(true); + expect(isValidCnpj(cnpj)).toBe(true); + }); + + test("should write the branch as the ordem block in positions 9 to 12", () => { + const cnpj = generateCnpj({ branch: 1 }); + + expect(cnpj.slice(8, 12)).toBe("0001"); + expect(isValidCnpj(cnpj)).toBe(true); + }); + + test("should zero pad a branch shorter than the four character ordem block", () => { + expect(generateCnpj({ branch: 3 }).slice(8, 12)).toBe("0003"); + expect(generateCnpj({ branch: 42 }).slice(8, 12)).toBe("0042"); + expect(generateCnpj({ branch: 500 }).slice(8, 12)).toBe("0500"); + }); + + test("should keep the ordem block numeric on the alphanumeric version, with letters in the raiz", () => { + const raizChars = new Set(); + + for (let index = 0; index < 100; index++) { + const cnpj = generateCnpj({ version: 2, branch: 9999 }); + + expect(cnpj).toHaveLength(CNPJ_LENGTH); + expect(cnpj.slice(8, 12)).toBe("9999"); + expect(isValidCnpj(cnpj, { version: 2 })).toBe(true); + + for (const char of cnpj.slice(0, 8)) { + raizChars.add(char); + } + } + + expect([...raizChars].some((char) => /[A-Z]/.test(char))).toBe(true); + }); + + test("should generate a numeric CNPJ with a branch when the version is 1", () => { + const cnpj = generateCnpj({ version: 1, branch: 1234 }); + + expect(/^\d+$/.test(cnpj)).toBe(true); + expect(cnpj.slice(8, 12)).toBe("1234"); + expect(isValidCnpj(cnpj)).toBe(true); + }); + + for (const [label, branch] of INVALID_BRANCHES) { + test(`should draw a random ordem block when the branch is ${label}`, () => { + const cnpj = generateWithForcedDraws(BRANCH_FALLBACK_DRAWS, 10, () => + generateCnpj({ branch }), + ); + + expect(cnpj).toBe("12345678901230"); + expect(isValidCnpj(cnpj)).toBe(true); + }); + } + + test("should draw a random ordem block when the branch is a string", () => { + const cnpj = generateWithForcedDraws(BRANCH_FALLBACK_DRAWS, 10, () => + // @ts-expect-error: intentionally invalid input + generateCnpj({ branch: "3" }), + ); + + expect(cnpj).toBe("12345678901230"); + }); + + test("should draw a random ordem block when the branch is null", () => { + const cnpj = generateWithForcedDraws(BRANCH_FALLBACK_DRAWS, 10, () => + // @ts-expect-error: intentionally invalid input + generateCnpj({ branch: null }), + ); + + expect(cnpj).toBe("12345678901230"); + }); + + test("should ignore an unknown version in the options object and generate a numeric CNPJ", () => { + // @ts-expect-error: intentionally invalid input + const cnpj = generateCnpj({ version: 3 }); + + expect(/^\d+$/.test(cnpj)).toBe(true); + expect(isValidCnpj(cnpj)).toBe(true); + }); + }); + describe("properties", () => { const batchSize = fc.integer({ min: 1, max: 10 }); @@ -189,12 +285,30 @@ describe("generateCnpj", () => { }), ); }); + + test("should write any ordem from 1 to 9999 into positions 9 to 12 of both versions", () => { + fc.assert( + fc.property(fc.integer({ min: 1, max: 9999 }), (branch) => { + const padded = `000${branch}`.slice(-4); + + expect(generateCnpj({ branch }).slice(8, 12)).toBe(padded); + expect(generateCnpj({ version: 2, branch }).slice(8, 12)).toBe(padded); + }), + ); + }); }); }); describe("generateCnpj types", () => { - test("should take an optional version and return a string", () => { - expectTypeOf(generateCnpj).parameter(0).toEqualTypeOf<1 | 2 | undefined>(); + test("should take an optional version or options object and return a string", () => { + expectTypeOf(generateCnpj) + .parameter(0) + .toEqualTypeOf<1 | 2 | GenerateCnpjOptions | undefined>(); expectTypeOf(generateCnpj).returns.toEqualTypeOf(); }); + + test("should take an optional version and branch in the options object", () => { + expectTypeOf().toEqualTypeOf<1 | 2 | undefined>(); + expectTypeOf().toEqualTypeOf(); + }); }); diff --git a/src/generate-cnpj/generate-cnpj.ts b/src/generate-cnpj/generate-cnpj.ts index a8cf2d75..2cf05815 100644 --- a/src/generate-cnpj/generate-cnpj.ts +++ b/src/generate-cnpj/generate-cnpj.ts @@ -3,21 +3,58 @@ import { generateChecksum } from "../_internals/generate-checksum/generate-check import { generateRandomNumber } from "../_internals/generate-random-number/generate-random-number"; import { isRepeatedDigits } from "../_internals/is-repeated-digits/is-repeated-digits"; -const BASE_LENGTH = 12; +const ROOT_LENGTH = 8; + +const BRANCH_LENGTH = 4; + +const MIN_BRANCH = 1; + +const MAX_BRANCH = 9999; const VALID_CNPJ_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; -const generateRandomCnpjChar = (): string => - VALID_CNPJ_CHARS.charAt(Math.floor(Math.random() * VALID_CNPJ_CHARS.length)); +/** + * The options `generateCnpj` accepts, an alternative to passing the version positionally. + */ +export type GenerateCnpjOptions = { + /** + * The version of the CNPJ to be generated: `1` for the numeric CNPJ and `2` for the + * alphanumeric one. Defaults to `1`, and any other runtime value also generates a version 1 + * (numeric) CNPJ. + */ + version?: 1 | 2; + /** + * The "número de ordem" (filial) block, positions 9 to 12 of the CNPJ: an integer from 1 to + * 9999, written zero padded to four characters (`3` becomes `"0003"`). Defaults to a random + * block, and an integer outside that range, a fractional number or any other runtime value is + * ignored, so a random block is used for those as well. The block stays numeric on the + * alphanumeric version, which the IN RFB nº 2.229/2024 layout allows. + */ + branch?: number; +}; -const generateAlphanumericCnpjBase = (): string => { - let base = ""; - for (let i = 0; i < BASE_LENGTH; i++) { - base += generateRandomCnpjChar(); +const generateRandomCnpjChars = (length: number): string => { + let chars = ""; + for (let i = 0; i < length; i++) { + chars += VALID_CNPJ_CHARS.charAt(Math.floor(Math.random() * VALID_CNPJ_CHARS.length)); } - return base; + return chars; }; +const isInteger = (value: unknown): value is number => Number.isInteger(value); + +const isBranchInRange = (branch: number | undefined): branch is number => + isInteger(branch) && branch >= MIN_BRANCH && branch <= MAX_BRANCH; + +const generateBase = ( + branch: number | undefined, + generatePart: (length: number) => string, +): string => + generatePart(ROOT_LENGTH) + + (isBranchInRange(branch) + ? branch.toString().padStart(BRANCH_LENGTH, "0") + : generatePart(BRANCH_LENGTH)); + const generateNonRepeatedBase = (generate: () => string): string => { let base = generate(); while (isRepeatedDigits(base)) { @@ -41,15 +78,15 @@ const calculateAlphanumericCheckDigit = (base: string, weights: number[]): strin return (mod < 2 ? 0 : 11 - mod).toString(); }; -const generateNumericCnpj = (): string => { - const base = generateNonRepeatedBase(() => generateRandomNumber(BASE_LENGTH)); +const generateNumericCnpj = (branch: number | undefined): string => { + const base = generateNonRepeatedBase(() => generateBase(branch, generateRandomNumber)); const firstCheckDigit = calculateCheckDigit(base, CNPJ_FIRST_DIGIT_WEIGHTS); const secondCheckDigit = calculateCheckDigit(base + firstCheckDigit, CNPJ_SECOND_DIGIT_WEIGHTS); return base + firstCheckDigit + secondCheckDigit; }; -const generateAlphanumericCnpj = (): string => { - const base = generateNonRepeatedBase(generateAlphanumericCnpjBase); +const generateAlphanumericCnpj = (branch: number | undefined): string => { + const base = generateNonRepeatedBase(() => generateBase(branch, generateRandomCnpjChars)); const firstCheckDigit = calculateAlphanumericCheckDigit(base, CNPJ_FIRST_DIGIT_WEIGHTS); const secondCheckDigit = calculateAlphanumericCheckDigit( base + firstCheckDigit, @@ -58,25 +95,50 @@ const generateAlphanumericCnpj = (): string => { return base + firstCheckDigit + secondCheckDigit; }; +const isGenerateCnpjOptions = ( + versionOrOptions: 1 | 2 | GenerateCnpjOptions, +): versionOrOptions is GenerateCnpjOptions => + typeof versionOrOptions === "object" && versionOrOptions !== null; + /** * Generates a valid random CNPJ (Cadastro Nacional da Pessoa Jurídica). * * Uses `Math.random()` internally, so it is not cryptographically secure, do not use for security purposes. * - * @param {1 | 2} version - The version of the CNPJ to be generated: `1` for the numeric CNPJ and - * `2` for the alphanumeric one. Defaults to `1`, and never throws: `null`, `undefined` and any - * other runtime value that is not `2` also generate a version 1 (numeric) CNPJ. + * The first argument is either the version, as it has always been, or a `GenerateCnpjOptions` + * object carrying that same version plus the "número de ordem" (filial) block to write in + * positions 9 to 12. + * + * @param {1 | 2 | GenerateCnpjOptions} [versionOrOptions] - The version of the CNPJ to be + * generated: `1` for the numeric CNPJ and `2` for the alphanumeric one, or an options object. + * Defaults to `1`, and never throws: `null`, `undefined` and any other runtime value that is + * neither `2` nor an object also generate a version 1 (numeric) CNPJ. + * @param {1 | 2} [versionOrOptions.version] - The version of the CNPJ to be generated, as above. + * @param {number} [versionOrOptions.branch] - The "número de ordem" (filial) block, an integer + * from 1 to 9999 written zero padded to four characters. Defaults to a random block, and an + * invalid branch is ignored rather than reported, so a random block is used for it too. * @returns {string} A valid 14-digit CNPJ string without formatting. * * @example * ```typescript * generateCnpj(); // "12345678000195" * generateCnpj(2); // "Q0SLFMBD7VX439" + * generateCnpj({ version: 2 }); // "Q0SLFMBD7VX439" + * generateCnpj({ branch: 3 }); // "12345678000372", the ordem block is "0003" + * generateCnpj({ version: 2, branch: 1 }); // "Q0SLFMBD000148", the ordem block is "0001" + * generateCnpj({ branch: 0 }); // "12345678472695", an out of range branch draws a random block * ``` * * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/orientacao-tributaria/cadastros/cnpj * @see Official: https://www.gov.br/receitafederal/pt-br/centrais-de-conteudo/publicacoes/documentos-tecnicos/cnpj/manual-dv-cnpj.pdf * @see Official: https://www.gov.br/receitafederal/pt-br/acesso-a-informacao/acoes-e-programas/programas-e-atividades/cnpj-alfanumerico */ -export const generateCnpj = (version: 1 | 2 = 1): string => - version === 2 ? generateAlphanumericCnpj() : generateNumericCnpj(); +export const generateCnpj = (versionOrOptions: 1 | 2 | GenerateCnpjOptions = 1): string => { + const options: GenerateCnpjOptions = isGenerateCnpjOptions(versionOrOptions) + ? versionOrOptions + : { version: versionOrOptions }; + + return options.version === 2 + ? generateAlphanumericCnpj(options.branch) + : generateNumericCnpj(options.branch); +}; diff --git a/src/generate-cpf/generate-cpf.test.ts b/src/generate-cpf/generate-cpf.test.ts index 8b92d810..63beca5b 100644 --- a/src/generate-cpf/generate-cpf.test.ts +++ b/src/generate-cpf/generate-cpf.test.ts @@ -2,6 +2,7 @@ import * as fc from "fast-check"; import { CPF_LENGTH } from "../_internals/constants/cpf"; import { DATA, type StateCode } from "../_internals/constants/states"; +import { PROTOTYPE_KEYS } from "../_internals/test/arbitraries"; import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; import { isValidCpf } from "../is-valid-cpf/is-valid-cpf"; import { STATE_CODES } from "./constants"; @@ -69,9 +70,31 @@ describe("generateCpf", () => { expect(isValidCpf(cpf)).toBe(true); }); + test("should fall back to a random digit instead of reaching the prototype chain for a state code", () => { + for (const key of PROTOTYPE_KEYS) { + // @ts-expect-error: intentionally invalid input + const cpf = generateCpf(key); + expect(cpf).toHaveLength(CPF_LENGTH); + expect(isValidCpf(cpf)).toBe(true); + } + }); + + test("should fall back to a random digit instead of throwing for a state code with no string conversion", () => { + const nullPrototype = generateCpf(Object.create(null)); + const throwing = generateCpf({ + toString() { + throw new Error("no string conversion"); + }, + } as unknown as StateCode); + + expect(isValidCpf(nullPrototype)).toBe(true); + expect(isValidCpf(throwing)).toBe(true); + }); + describe("properties", () => { const stateCode = fc.constantFrom(...DATA.map((state) => state.code)); const batchSize = fc.integer({ min: 1, max: 10 }); + const hostileStateCode = fc.oneof(fc.constantFrom(...PROTOTYPE_KEYS), fc.anything()); test("should generate a valid CPF carrying the state digit of every state", () => { fc.assert( @@ -85,6 +108,17 @@ describe("generateCpf", () => { ); }); + test("should generate a valid CPF for any state code at all, prototype chain keys included", () => { + fc.assert( + fc.property(hostileStateCode, (state) => { + const cpf = generateCpf(state as StateCode); + + expect(cpf).toMatch(/^\d{11}$/); + expect(isValidCpf(cpf)).toBe(true); + }), + ); + }); + test("should never draw a base made of a single repeated digit", () => { fc.assert( fc.property(batchSize, (size) => { diff --git a/src/generate-cpf/generate-cpf.ts b/src/generate-cpf/generate-cpf.ts index 115a1e10..d651814e 100644 --- a/src/generate-cpf/generate-cpf.ts +++ b/src/generate-cpf/generate-cpf.ts @@ -4,8 +4,19 @@ import { generateRandomNumber } from "../_internals/generate-random-number/gener import { isRepeatedDigits } from "../_internals/is-repeated-digits/is-repeated-digits"; import { BASE_LENGTH, STATE_CODES } from "./constants"; +export type { StateCode } from "../_internals/constants/states"; + +/** + * The região fiscal digit of a state, a random one for anything else. The state is read as a + * string before the own property lookup, so a value with no string conversion (an object created + * with `Object.create(null)`, one whose `toString` throws) is an unknown state rather than a + * `TypeError` thrown while `Object.hasOwn` coerces it into a property key. + * + * @param {StateCode} [state] - The state code the CPF is generated for, if any. + * @returns {string} The região fiscal digit of that state, or a random digit. + */ const getStateCode = (state?: StateCode): string => { - if (state && Object.hasOwn(STATE_CODES, state)) return STATE_CODES[state]; + if (typeof state === "string" && Object.hasOwn(STATE_CODES, state)) return STATE_CODES[state]; return generateRandomNumber(1); }; @@ -19,7 +30,9 @@ const calculateCheckDigit = (base: string, weight: number): string => { * * Uses `Math.random()` internally, so it is not cryptographically secure, do not use for security purposes. * - * @param {StateCode} state - Optional. The Brazilian state code to generate a CPF for. + * @param {StateCode} [state] - The Brazilian state code to generate a CPF for. An unknown state + * draws a random região fiscal digit instead of throwing, a key of the prototype chain + * (`"__proto__"`, `"constructor"`) and a value with no string conversion included. * @returns {string} A valid 11-digit CPF string without formatting. * * @example @@ -28,9 +41,20 @@ const calculateCheckDigit = (base: string, weight: number): string => { * generateCpf("SP"); // "12345678810" (with the SP state code, 8, in the 9th digit) * ``` * + * The região fiscal digit in the 9th position comes from the Receita Federal's folheto + * "Cadastros: CPF e CNPJ"; the check digit rule (`REGRA_VALIDA_CPF`) is specified, with the + * worked example `280012389-38`, in the Receita Federal's Manual de Preenchimento da + * e-Financeira, Anexo II — Leiautes Gerais, approved by the Ato Declaratório Executivo Cofis + * nº 10, de 19 de maio de 2026 (DOU de 25/05/2026). The manual's own file used to be served from + * `sped.rfb.gov.br`, + * a host that no longer answers at all, so the approving act is cited below in its place; its + * Receita Federal permalink redirects into the norms viewer, which has to be opened in a + * browser. + * * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/meu-cpf * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/educacao-fiscal/educacao_fiscal/folhetos-orientativos/cadastros-dig.pdf - * @see Based on: https://github.com/brazilian-utils/brutils-python/blob/main/brutils/cpf.py + * @see Official: https://normas.receita.fazenda.gov.br/sijut2consulta/link.action?idAto=151372 + * @see Based on: https://github.com/brazilian-utils/python/blob/main/brutils/cpf.py */ export const generateCpf = (state?: StateCode): string => { let base = generateRandomNumber(BASE_LENGTH) + getStateCode(state); diff --git a/src/generate-legal-nature/generate-legal-nature.ts b/src/generate-legal-nature/generate-legal-nature.ts index 72cce239..031b4173 100644 --- a/src/generate-legal-nature/generate-legal-nature.ts +++ b/src/generate-legal-nature/generate-legal-nature.ts @@ -12,6 +12,10 @@ import { LEGAL_NATURE } from "../is-valid-legal-nature/constants"; * generateLegalNature(); // "2062" * ``` * + * The CONCLA table page sits behind a bot filter and answers HTTP 403 to every non-browser + * client, so it has to be opened in a browser; the detailed structure PDF next to it is served + * normally. + * * @see Official: https://concla.ibge.gov.br/estrutura/natjur-estrutura/natureza-juridica-2021 * @see Official: https://concla.ibge.gov.br/images/concla/documentacao/CONCLA-TNJ2021-EstruturaDetalhada.pdf */ diff --git a/src/generate-license-plate/generate-license-plate.ts b/src/generate-license-plate/generate-license-plate.ts index 946ad6e2..0e19fa25 100644 --- a/src/generate-license-plate/generate-license-plate.ts +++ b/src/generate-license-plate/generate-license-plate.ts @@ -17,9 +17,9 @@ const randomDigit = (): string => Math.floor(Math.random() * 10).toString(); * Uses `Math.random()` internally, so it is not cryptographically secure, do not use for * security purposes. * - * @param {GenerateLicensePlateFormat} format - The format to generate. Defaults to the - * Mercosul format ("LLLNLNN"), the single sequence Resolução CONTRAN nº 969/2022 defines - * for every vehicle, motorcycles included. + * @param {GenerateLicensePlateFormat} [format] - The format to generate. Defaults to the + * Mercosul format ("LLLNLNN"), the single sequence Resolução CONTRAN nº 969/2022 defines for + * every vehicle, motorcycles included. * @returns {string} A randomly generated license plate matching the requested format. * * @example @@ -28,7 +28,22 @@ const randomDigit = (): string => Math.floor(Math.random() * 10).toString(); * generateLicensePlate("LLLNNNN"); // "ABC1234" (old Brazilian format) * ``` * + * The resolution's own text does not spell the sequence out: art. 2º § 2º delegates the + * technical specification to Anexo I, whose item 1.2 reads "O padrão de estampagem é composto de + * 7 (sete) caracteres alfanuméricos, em alto relevo, na sequência LLLNLNN" and whose item 1.2.1 + * reads `L` as a letter and `N` as a numeral. The annexes are published in a PDF of their own, + * cited below alongside the resolution's text. + * + * A `format` string outside the two supported literals is not rejected: it is used verbatim, + * character by character, `L` producing a letter and every other position a digit, which is the + * 2.3.0 behaviour and is kept for the JavaScript callers the type cannot reach. So + * `generateLicensePlate("LLLNNLN")` returns a plate in the withdrawn motorcycle sequence, which + * `isValidLicensePlate` rejects, `generateLicensePlate("bogus")` returns five digits and + * `generateLicensePlate("")` returns an empty string. Only a non-string falls back to the + * default. Pass one of the two literals to get a plate the library considers valid. + * * @see Official: https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes/resolucao9692022.pdf + * @see Official: https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes/resolucao9692022anexos.pdf */ export const generateLicensePlate = ( format: GenerateLicensePlateFormat = DEFAULT_FORMAT, diff --git a/src/generate-passport/generate-passport.ts b/src/generate-passport/generate-passport.ts index 05c905e1..6e1f63fc 100644 --- a/src/generate-passport/generate-passport.ts +++ b/src/generate-passport/generate-passport.ts @@ -13,6 +13,7 @@ import { ALPHABET_LENGTH, CHAR_CODE_A, DIGITS_LENGTH, LETTERS_LENGTH } from "./c * generatePassport() // "ZS840088" * * @see Official: https://www.gov.br/pf/pt-br/assuntos/passaporte + * @see Official: https://www.gov.br/pf/pt-br/assuntos/passaporte/ajuda/duvidas_/caderneta/caderneta-numero-onde-fica-e */ export const generatePassport = (): string => { const letters = Array.from({ length: LETTERS_LENGTH }, () => diff --git a/src/generate-pix-payload/generate-pix-payload.test.ts b/src/generate-pix-payload/generate-pix-payload.test.ts index da8b1f81..0f142d84 100644 --- a/src/generate-pix-payload/generate-pix-payload.test.ts +++ b/src/generate-pix-payload/generate-pix-payload.test.ts @@ -4,9 +4,12 @@ import { crc16Ccitt } from "../_internals/crc16-ccitt/crc16-ccitt"; import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; import { generateCnpj } from "../generate-cnpj/generate-cnpj"; import { generateCpf } from "../generate-cpf/generate-cpf"; +import { + type PixPointOfInitiation, + getPixPayloadInfo, +} from "../get-pix-payload-info/get-pix-payload-info"; import { isValidPixPayload } from "../is-valid-pix-payload/is-valid-pix-payload"; -import { type PixPointOfInitiation, parsePixPayload } from "../parse-pix-payload/parse-pix-payload"; -import { type GeneratePixPayloadParams, generatePixPayload } from "./generate-pix-payload"; +import { type GeneratePixPayloadOptions, generatePixPayload } from "./generate-pix-payload"; const BASE = { key: "123e4567-e12b-12d1-a456-426655440000", @@ -267,8 +270,8 @@ describe("generatePixPayload", () => { expect(isValidPixPayload(generatePixPayload(DYNAMIC_BASE) ?? "")).toBe(true); }); - test("that parsePixPayload parses back with pointOfInitiation dynamic and no key", () => { - expect(parsePixPayload(generatePixPayload(DYNAMIC_BASE) ?? "")).toEqual({ + test("that getPixPayloadInfo parses back with pointOfInitiation dynamic and no key", () => { + expect(getPixPayloadInfo(generatePixPayload(DYNAMIC_BASE) ?? "")).toEqual({ url: "pix.example.com/qr/v2/1234", merchantName: "Fulano de Tal", merchantCity: "Brasilia", @@ -284,26 +287,26 @@ describe("generatePixPayload", () => { const payload = generatePixPayload({ ...DYNAMIC_BASE, url }); expect(payload).not.toBeNull(); - expect(parsePixPayload(payload ?? "")?.url).toBe(url); + expect(getPixPayloadInfo(payload ?? "")?.url).toBe(url); }); }); describe("should normalize its parameters", () => { test("folding accents out of the merchant name and city", () => { expect( - parsePixPayload(generatePixPayload({ ...BASE, merchantCity: "Brasília" }) ?? ""), + getPixPayloadInfo(generatePixPayload({ ...BASE, merchantCity: "Brasília" }) ?? ""), ).toMatchObject({ merchantCity: "Brasilia", }); expect( - parsePixPayload(generatePixPayload({ ...BASE, merchantName: "José Antônio" }) ?? ""), + getPixPayloadInfo(generatePixPayload({ ...BASE, merchantName: "José Antônio" }) ?? ""), ).toMatchObject({ merchantName: "Jose Antonio", }); }); test("trimming trailing whitespace introduced by truncating to the maximum length", () => { - const pix = parsePixPayload( + const pix = getPixPayloadInfo( generatePixPayload({ ...BASE, merchantName: `${"A".repeat(24)} B` }) ?? "", ); @@ -311,7 +314,7 @@ describe("generatePixPayload", () => { }); test("truncating the merchant name to 25 characters", () => { - const pix = parsePixPayload( + const pix = getPixPayloadInfo( generatePixPayload({ ...BASE, merchantName: "A".repeat(40) }) ?? "", ); @@ -319,7 +322,7 @@ describe("generatePixPayload", () => { }); test("truncating the merchant city to 15 characters", () => { - const pix = parsePixPayload( + const pix = getPixPayloadInfo( generatePixPayload({ ...BASE, merchantCity: "B".repeat(40) }) ?? "", ); @@ -328,17 +331,17 @@ describe("generatePixPayload", () => { test("normalizing the key to its DICT canonical form", () => { expect( - parsePixPayload(generatePixPayload({ ...BASE, key: "123.456.789-09" }) ?? "")?.key, + getPixPayloadInfo(generatePixPayload({ ...BASE, key: "123.456.789-09" }) ?? "")?.key, ).toBe("12345678909"); expect( - parsePixPayload(generatePixPayload({ ...BASE, key: "(11) 98765-4321" }) ?? "")?.key, + getPixPayloadInfo(generatePixPayload({ ...BASE, key: "(11) 98765-4321" }) ?? "")?.key, ).toBe("+5511987654321"); expect( - parsePixPayload(generatePixPayload({ ...BASE, key: " Fulano@Example.COM " }) ?? "")?.key, + getPixPayloadInfo(generatePixPayload({ ...BASE, key: " Fulano@Example.COM " }) ?? "")?.key, ).toBe("fulano@example.com"); const upperCaseEvp = EVP.toUpperCase(); - expect(parsePixPayload(generatePixPayload({ ...BASE, key: upperCaseEvp }) ?? "")?.key).toBe( + expect(getPixPayloadInfo(generatePixPayload({ ...BASE, key: upperCaseEvp }) ?? "")?.key).toBe( EVP, ); }); @@ -350,7 +353,7 @@ describe("generatePixPayload", () => { description: "y".repeat(90), }); - expect(parsePixPayload(payload ?? "")?.description).toBe("y".repeat(62)); + expect(getPixPayloadInfo(payload ?? "")?.description).toBe("y".repeat(62)); }); test("truncating the description to what a mobile phone key leaves", () => { @@ -360,28 +363,28 @@ describe("generatePixPayload", () => { description: "y".repeat(90), }); - expect(parsePixPayload(payload ?? "")?.description).toBe("y".repeat(59)); + expect(getPixPayloadInfo(payload ?? "")?.description).toBe("y".repeat(59)); }); test("leaving room for the description on a long key", () => { const key = `${"a".repeat(56)}@example.com`; const payload = generatePixPayload({ ...BASE, key, description: "z".repeat(30) }) ?? ""; - expect(parsePixPayload(payload)?.description).toBe("z".repeat(5)); + expect(getPixPayloadInfo(payload)?.description).toBe("z".repeat(5)); }); test("dropping a description that does not fit at all", () => { const key = `${"a".repeat(65)}@example.com`; const payload = generatePixPayload({ ...BASE, key, description: "z".repeat(30) }) ?? ""; - expect(parsePixPayload(payload)).not.toHaveProperty("description"); + expect(getPixPayloadInfo(payload)).not.toHaveProperty("description"); }); }); describe("should round-trip", () => { const ROUND_TRIPS: { name: string; - build: (index: number) => GeneratePixPayloadParams; + build: (index: number) => GeneratePixPayloadOptions; pointOfInitiation: PixPointOfInitiation; }[] = [ { @@ -416,13 +419,13 @@ describe("generatePixPayload", () => { ]; for (const { name, build, pointOfInitiation } of ROUND_TRIPS) { - test(`through isValidPixPayload and parsePixPayload for ${name}`, () => { + test(`through isValidPixPayload and getPixPayloadInfo for ${name}`, () => { for (let index = 0; index < 200; index++) { const params = build(index); const payload = generatePixPayload(params) ?? ""; expect(isValidPixPayload(payload)).toBe(true); - expect(parsePixPayload(payload)).toEqual({ ...params, pointOfInitiation }); + expect(getPixPayloadInfo(payload)).toEqual({ ...params, pointOfInitiation }); } }); } @@ -439,11 +442,11 @@ describe("generatePixPayload", () => { const cents = fc.integer({ min: 1, max: 9_999_999 }); - test("should round-trip a static payload through parsePixPayload", () => { + test("should round-trip a static payload through getPixPayloadInfo", () => { fc.assert( fc.property(names, cities, (merchantName, merchantCity) => { const payload = generatePixPayload({ key: CPF_KEY, merchantName, merchantCity }); - const parsed = parsePixPayload(payload ?? ""); + const parsed = getPixPayloadInfo(payload ?? ""); expect(isValidPixPayload(payload ?? "")).toBe(true); expect(parsed?.key).toBe(CPF_KEY); @@ -466,7 +469,7 @@ describe("generatePixPayload", () => { amount, txid, }); - const parsed = parsePixPayload(payload ?? ""); + const parsed = getPixPayloadInfo(payload ?? ""); expect(parsed?.amount).toBe(Number(amount.toFixed(2))); expect(parsed?.txid).toBe(txid); @@ -478,7 +481,7 @@ describe("generatePixPayload", () => { fc.assert( fc.property(names, urls, (merchantName, url) => { const payload = generatePixPayload({ url, merchantName, merchantCity: "BRASILIA" }); - const parsed = parsePixPayload(payload ?? ""); + const parsed = getPixPayloadInfo(payload ?? ""); expect(parsed?.url).toBe(url); expect(parsed?.pointOfInitiation).toBe("dynamic"); @@ -508,7 +511,7 @@ describe("generatePixPayload", () => { fc.pre(payload !== null); - const parsed = parsePixPayload(payload ?? ""); + const parsed = getPixPayloadInfo(payload ?? ""); expect(/^[\u0020-\u007E]{1,25}$/.test(parsed?.merchantName ?? "")).toBe(true); expect(/^[\u0020-\u007E]{1,15}$/.test(parsed?.merchantCity ?? "")).toBe(true); @@ -521,12 +524,12 @@ describe("generatePixPayload", () => { describe("generatePixPayload types", () => { test("should take Pix payload params and return a string or null", () => { - expectTypeOf(generatePixPayload).parameter(0).toEqualTypeOf(); + expectTypeOf(generatePixPayload).parameter(0).toEqualTypeOf(); expectTypeOf(generatePixPayload).returns.toEqualTypeOf(); }); test("should restrict the params to the documented fields", () => { - expectTypeOf().toEqualTypeOf<{ + expectTypeOf().toEqualTypeOf<{ key?: string; url?: string; merchantName: string; diff --git a/src/generate-pix-payload/generate-pix-payload.ts b/src/generate-pix-payload/generate-pix-payload.ts index a6640ab4..24cffaf1 100644 --- a/src/generate-pix-payload/generate-pix-payload.ts +++ b/src/generate-pix-payload/generate-pix-payload.ts @@ -34,7 +34,7 @@ import { formatTlv } from "../_internals/format-tlv/format-tlv"; import { isNullish } from "../_internals/is-nullish/is-nullish"; import { isValidPixUrl } from "../_internals/is-valid-pix-url/is-valid-pix-url"; import { sanitizeToAscii } from "../_internals/sanitize-to-ascii/sanitize-to-ascii"; -import { parsePixKey } from "../parse-pix-key/parse-pix-key"; +import { getPixKeyInfo } from "../get-pix-key-info/get-pix-key-info"; import { AMOUNT_DECIMAL_PLACES, AMOUNT_REGEX, @@ -43,8 +43,8 @@ import { TXID_REGEX, } from "./constants"; -/** The parameters `generatePixPayload` takes to build a Pix BR Code. */ -export type GeneratePixPayloadParams = { +/** The options `generatePixPayload` takes to build a Pix BR Code. */ +export type GeneratePixPayloadOptions = { /** The Pix key of the receiver, in any accepted form. Required unless `url` is given. */ key?: string; /** @@ -92,7 +92,7 @@ const resolveIdentifier = ( }; } - const key = parsePixKey(keyInput); + const key = getPixKeyInfo(keyInput); if (!key) return null; @@ -139,7 +139,7 @@ const resolveFormattedAmount = ( * given and when neither is given, since only one of them can occupy the "Merchant Account * Information" template at a time. * - * When `params.key` is given, it is normalized to its DICT canonical form by `parsePixKey` and + * When `params.key` is given, it is normalized to its DICT canonical form by `getPixKeyInfo` and * the payload is static: the "Point of Initiation Method" object is left out, so the payload * may be paid more than once, as in the example of the Bacen manual. * @@ -147,17 +147,18 @@ const resolveFormattedAmount = ( * Iniciação do Pix: the URL takes the key's place in the "Merchant Account Information" * template (sub-object `25` instead of `01`) and the "Point of Initiation Method" object (`01`) * is set to `"12"`. `params.url` must be at most 77 characters, the length that keeps the - * template within its 99 character limit together with the `br.gov.bcb.pix` GUI. `parsePixPayload` - * already parses both shapes, so `parsePixPayload(generatePixPayload({ url, ... }))` round-trips. + * template within its 99 character limit together with the `br.gov.bcb.pix` GUI. `getPixPayloadInfo` + * already parses both shapes, so `getPixPayloadInfo(generatePixPayload({ url, ... }))` round-trips. * * Object `01` is optional in the Manual do BR Code (`Uso: O`), so writing it only for a dynamic - * payload is one of the shapes the manual allows and follows its own examples; `parsePixPayload` + * payload is one of the shapes the manual allows and follows its own examples; `getPixPayloadInfo` * accepts the others too. The Pix Saque BR Code, which announces the ISPB of the "facilitador de * serviço de saque" in sub-object 26-03 (`fss`), is not generated here, only parsed. * - * Payloads that carry the location in an Unreserved Template (IDs 80 to 99), as the "QR Code - * composto" of Pix Automático (Pix recorrente) does, are out of scope: the location is always - * written in the "Merchant Account Information" template. + * Unreserved Templates (IDs 80 to 99) are never written: the location always goes in the + * "Merchant Account Information" template, so the "QR Code composto" of Pix Automático (Pix + * recorrente), which puts its recurrence location in one of them, is out of scope here. + * `getPixPayloadInfo` does read a composto, but only as an ordinary dynamic payload. * * The merchant name, the merchant city and the description are folded to printable ASCII * (accents are dropped) and truncated to the lengths the BR Code allows, the description to @@ -167,7 +168,7 @@ const resolveFormattedAmount = ( * does not survive that round trip (`0.005`, `123.456`) is refused rather than rounded into a * payload that asks the payer for a different sum. * - * @param {GeneratePixPayloadParams} params - The parameters of the payload. + * @param {GeneratePixPayloadOptions} params - The parameters of the payload. * @param {string} [params.key] - The Pix key of the receiver. Required unless `url` is given. * @param {string} [params.url] - The PSP location of a dynamic payload. Required unless `key` * is given. @@ -202,10 +203,12 @@ const resolveFormattedAmount = ( * * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/spb_docs/ManualBRCode.pdf * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/pix/Regulamento_Pix/II_ManualdePadroesparaIniciacaodoPix.pdf - * @see Official: https://github.com/bacen/pix-api Pix (SPI) OpenAPI spec. - * @see Official: https://github.com/bacen/pix-dict-api DICT OpenAPI spec. + * @see Official: https://github.com/bacen/pix-api + * Pix (SPI) OpenAPI spec. + * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/pix/API-DICT.html + * DICT (Diretório de Identificadores de Contas Transacionais) API specification. */ -export const generatePixPayload = (params: GeneratePixPayloadParams): string | null => { +export const generatePixPayload = (params: GeneratePixPayloadOptions): string | null => { if (isNullish(params) || typeof params !== "object") return null; const { key: keyInput, url: urlInput } = params; diff --git a/src/generate-processo-juridico/generate-processo-juridico.test.ts b/src/generate-processo-juridico/generate-processo-juridico.test.ts index 92adccc5..568208cd 100644 --- a/src/generate-processo-juridico/generate-processo-juridico.test.ts +++ b/src/generate-processo-juridico/generate-processo-juridico.test.ts @@ -1,6 +1,9 @@ import * as fc from "fast-check"; -import { PROCESSO_JURIDICO_LENGTH } from "../_internals/constants/processo-juridico"; +import { + PROCESSO_JURIDICO_LENGTH, + PROCESSO_JURIDICO_TRIBUNALS, +} from "../_internals/constants/processo-juridico"; import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; import { isValidProcessoJuridico } from "../is-valid-processo-juridico/is-valid-processo-juridico"; import { @@ -16,6 +19,13 @@ const expectValidGeneratedProcessoJuridico = (value: string | null) => { expect(isValidProcessoJuridico(value as string)).toBe(true); }; +const expectListedCourtAndTribunal = (value: string | null) => { + const court = Number((value as string).charAt(13)); + const tribunal = Number((value as string).slice(14, 16)); + + expect(PROCESSO_JURIDICO_TRIBUNALS.get(court)).toContain(tribunal); +}; + describe("generateProcessoJuridico", () => { it("should generate a valid processo juridico", () => { expectValidGeneratedProcessoJuridico(generateProcessoJuridico()); @@ -28,10 +38,11 @@ describe("generateProcessoJuridico", () => { }); it("should honor the year and court options", () => { - const value = generateProcessoJuridico({ year: currentYear(), court: 5 }); + const year = currentYear(); + const value = generateProcessoJuridico({ year, court: 5 }); expect(value).not.toBe(null); - expect((value as string).slice(9, 13)).toBe(String(currentYear())); + expect((value as string).slice(9, 13)).toBe(String(year)); expect((value as string).charAt(13)).toBe("5"); expect(isValidProcessoJuridico(value as string)).toBe(true); }); @@ -68,6 +79,34 @@ describe("generateProcessoJuridico", () => { expect(generateProcessoJuridico(42)).toBe(null); }); + it("should draw a tribunal the órgão really has for every court option", () => { + for (const court of PROCESSO_JURIDICO_TRIBUNALS.keys()) { + const value = generateProcessoJuridico({ court }); + + expectValidGeneratedProcessoJuridico(value); + expect((value as string).charAt(13)).toBe(String(court)); + expectListedCourtAndTribunal(value); + } + }); + + it("should zero the tribunal of a segment whose only listed code is the superior court", () => { + expect(generateProcessoJuridico({ court: 1 })?.slice(14, 16)).toBe("00"); + expect(generateProcessoJuridico({ court: 2 })?.slice(14, 16)).toBe("00"); + expect(generateProcessoJuridico({ court: 3 })?.slice(14, 16)).toBe("00"); + }); + + it("should pad a single digit tribunal to the two digits of the CNJ field", () => { + const originalRandom = Math.random; + + Math.random = () => 0; + + try { + expect(generateProcessoJuridico({ court: 4 })?.slice(14, 16)).toBe("01"); + } finally { + Math.random = originalRandom; + } + }); + it("should map a forced random value to the hand-computed default court", () => { const originalRandom = Math.random; @@ -88,9 +127,11 @@ describe("generateProcessoJuridico", () => { const court = fc.integer({ min: 1, max: 9 }); test("should embed every accepted year and court in a valid number", () => { + const thisYear = currentYear(); + fc.assert( fc.property(year, court, (chosenYear, chosenCourt) => { - fc.pre(chosenYear >= currentYear()); + fc.pre(chosenYear >= thisYear); const value = generateProcessoJuridico({ year: chosenYear, court: chosenCourt }); @@ -105,16 +146,28 @@ describe("generateProcessoJuridico", () => { test("should return null for every year outside the accepted range", () => { const outOfRangeYears = fc.integer({ min: -9999, max: 999_999 }); + const thisYear = currentYear(); fc.assert( fc.property(outOfRangeYears, (invalidYear) => { - fc.pre(invalidYear < currentYear() || invalidYear > 9999); + fc.pre(invalidYear < thisYear || invalidYear > 9999); expect(generateProcessoJuridico({ year: invalidYear })).toBe(null); }), ); }); + test("should only ever produce a valid number whose órgão and tribunal pair is listed", () => { + fc.assert( + fc.property(fc.option(court, { nil: undefined }), (chosenCourt) => { + const value = generateProcessoJuridico({ court: chosenCourt }); + + expectValidGeneratedProcessoJuridico(value); + expectListedCourtAndTribunal(value); + }), + ); + }); + test("should return null for every court outside 1 to 9", () => { const invalidCourts = fc .integer({ min: -100, max: 100 }) diff --git a/src/generate-processo-juridico/generate-processo-juridico.ts b/src/generate-processo-juridico/generate-processo-juridico.ts index 45050b77..9ad28656 100644 --- a/src/generate-processo-juridico/generate-processo-juridico.ts +++ b/src/generate-processo-juridico/generate-processo-juridico.ts @@ -1,3 +1,4 @@ +import { PROCESSO_JURIDICO_TRIBUNALS } from "../_internals/constants/processo-juridico"; import { generateRandomNumber } from "../_internals/generate-random-number/generate-random-number"; import { isNullish } from "../_internals/is-nullish/is-nullish"; @@ -10,8 +11,12 @@ export type GenerateProcessoJuridicoOptions = { }; const MAX_YEAR = 9999; -const MIN_COURT = 1; -const MAX_COURT = 9; +const TRIBUNAL_LENGTH = 2; + +const COURTS = [...PROCESSO_JURIDICO_TRIBUNALS.keys()]; + +const pick = (items: readonly Item[]): Item => + items[Math.floor(Math.random() * items.length)]; const calculateCheckDigits = (base: string): string => { const checksum = 98n - ((BigInt(base) * 100n) % 97n); @@ -22,6 +27,13 @@ const calculateCheckDigits = (base: string): string => { * Generates a random valid Brazilian Processo Jurídico (court case) number, * following the `NNNNNNNDDAAAAJTROOOO` layout of Resolução CNJ nº 65/2008. * + * The órgão (`J`) and the tribunal (`TR`) are drawn from the closed lists of art. 1º, § 4º and + * § 5º of the resolution, so the pair always names a court that exists: `court` picks the órgão + * and the `TR` is then drawn among the tribunais that órgão has, which is why a `court` outside + * 1 to 9, the only value with no tribunal to draw from, returns `null` instead of a number. The + * unidade de origem (`OOOO`) is drawn freely, since art. 1º, § 6º leaves its codification to each + * tribunal and publishes no central list. + * * Uses `Math.random()` internally, so it is not cryptographically secure, do not use for security purposes. * * @param {GenerateProcessoJuridicoOptions} [options] - Optional generation options. @@ -33,12 +45,14 @@ const calculateCheckDigits = (base: string): string => { * * @example * ```typescript - * generateProcessoJuridico(); // "00020802520265150049" - * generateProcessoJuridico({ year: 2030, court: 5 }); // "12345672820305120049" + * generateProcessoJuridico(); // "00020803420265150049" + * generateProcessoJuridico({ year: 2030, court: 5 }); // "12345679820305120049" * generateProcessoJuridico({ year: 10000 }); // null + * generateProcessoJuridico({ court: 10 }); // null (no such órgão) * ``` * - * Resolução CNJ nº 65/2008 defines this Número Único de Processo layout and its check digits. + * Resolução CNJ nº 65/2008 defines this Número Único de Processo layout and its check digits, and + * closes the list of órgão (`J`) and tribunal (`TR`) codes in art. 1º, § 4º and § 5º. * * @see Official: https://atos.cnj.jus.br/atos/detalhar/119 */ @@ -47,22 +61,16 @@ export const generateProcessoJuridico = ( ): string | null => { if (isNullish(options) || typeof options !== "object") return null; - const { year = new Date().getFullYear(), court = Math.floor(Math.random() * 9) + 1 } = options; + const { year = new Date().getFullYear(), court = pick(COURTS) } = options; const currentYear = new Date().getFullYear(); + const tribunals = PROCESSO_JURIDICO_TRIBUNALS.get(court); - if ( - !Number.isInteger(year) || - year < currentYear || - year > MAX_YEAR || - !Number.isInteger(court) || - court < MIN_COURT || - court > MAX_COURT - ) { + if (!Number.isInteger(year) || year < currentYear || year > MAX_YEAR || tribunals === undefined) { return null; } const sequencial = generateRandomNumber(7); - const tribunal = generateRandomNumber(2); + const tribunal = String(pick(tribunals)).padStart(TRIBUNAL_LENGTH, "0"); const foro = generateRandomNumber(4); const base = `${sequencial}${year}${court}${tribunal}${foro}`; const checkDigits = calculateCheckDigits(base); diff --git a/src/generate-renavam/generate-renavam.test.ts b/src/generate-renavam/generate-renavam.test.ts new file mode 100644 index 00000000..081c3ebb --- /dev/null +++ b/src/generate-renavam/generate-renavam.test.ts @@ -0,0 +1,93 @@ +import * as fc from "fast-check"; + +import { bench, describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; +import { isValidRenavam } from "../is-valid-renavam/is-valid-renavam"; +import { generateRenavam } from "./generate-renavam"; + +const generateWithDrawnDigits = (drawn: string): string => { + const realRandom = Math.random; + let position = 0; + + Math.random = (): number => { + const drawnDigit = Number(drawn.charAt(position)); + position += 1; + + return (drawnDigit + 0.5) / 10; + }; + + try { + return generateRenavam(); + } finally { + Math.random = realRandom; + } +}; + +describe("generateRenavam", () => { + test("should have the right length without mask (11)", () => { + expect(generateRenavam()).toHaveLength(11); + expect(/^\d{11}$/.test(generateRenavam())).toBe(true); + }); + + test("should always generate a valid RENAVAM", () => { + for (let i = 0; i < 1000; i++) { + expect(isValidRenavam(generateRenavam())).toBe(true); + } + }); + + test("should regenerate the base when it comes out with repeated digits", () => { + expect(generateWithDrawnDigits("00000000001234567890")).toBe("12345678900"); + }); + + test("should keep a check digit of 0 when the weighted product leaves a remainder of 10", () => { + expect(generateWithDrawnDigits("0000000006")).toBe("00000000060"); + }); + + test("should append the check digit the validator expects for a drawn base", () => { + expect(generateWithDrawnDigits("0063988496")).toBe("00639884962"); + expect(generateWithDrawnDigits("9000000000")).toBe("90000000006"); + }); + + describe("properties", () => { + const batchSize = fc.integer({ min: 1, max: 20 }); + + test("should generate 11 digit RENAVAM numbers its own validator accepts", () => { + fc.assert( + fc.property(batchSize, (size) => { + for (let index = 0; index < size; index++) { + const renavam = generateRenavam(); + + expect(renavam).toMatch(/^\d{11}$/); + expect(/^(\d)\1{9}/.test(renavam)).toBe(false); + expect(isValidRenavam(renavam)).toBe(true); + } + }), + ); + }); + + test("should generate registrations the usual mask characters do not change", () => { + fc.assert( + fc.property(batchSize, (size) => { + for (let index = 0; index < size; index++) { + const renavam = generateRenavam(); + + expect(isValidRenavam(`${renavam.slice(0, 7)}.${renavam.slice(7)}`)).toBe(true); + expect(isValidRenavam(` ${renavam.slice(0, 10)}-${renavam.slice(10)} `)).toBe(true); + } + }), + ); + }); + }); +}); + +describe("generateRenavam types", () => { + test("should take no parameters and return a string", () => { + expectTypeOf(generateRenavam).parameters.toEqualTypeOf<[]>(); + expectTypeOf(generateRenavam).returns.toEqualTypeOf(); + }); +}); + +describe("generateRenavam benchmarks", () => { + bench("generate a RENAVAM", () => { + generateRenavam(); + }); +}); diff --git a/src/generate-renavam/generate-renavam.ts b/src/generate-renavam/generate-renavam.ts new file mode 100644 index 00000000..1fd23071 --- /dev/null +++ b/src/generate-renavam/generate-renavam.ts @@ -0,0 +1,38 @@ +import { calculateRenavamCheckDigit } from "../_internals/calculate-renavam-check-digit/calculate-renavam-check-digit"; +import { generateRandomNumber } from "../_internals/generate-random-number/generate-random-number"; +import { isRepeatedDigits } from "../_internals/is-repeated-digits/is-repeated-digits"; + +const BASE_LENGTH = 10; + +/** + * Generates a valid random RENAVAM (Registro Nacional de Veículos Automotores) number. + * + * The result is always the eleven digit form: ten base digits followed by the check digit. A base + * whose digits are all the same is drawn again, since `isValidRenavam` rejects a registration like + * `"00000000000"`. + * + * Uses `Math.random()` internally, so it is not cryptographically secure, do not use for security purposes. + * + * @returns {string} A valid 11-digit RENAVAM string without formatting. + * + * @example + * ```typescript + * generateRenavam(); // "12345678900" + * ``` + * + * The Código de Trânsito Brasileiro creates the RENAVAM registry but does not define its check + * digit, so the algorithm follows the two community references cited as `Based on:`. + * + * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l9503compilado.htm + * @see Based on: https://github.com/klawdyo/validation-br/blob/main/src/renavam.ts + * @see Based on: https://github.com/brazilian-utils/python/blob/main/brutils/renavam.py + */ +export const generateRenavam = (): string => { + let base = generateRandomNumber(BASE_LENGTH); + + while (isRepeatedDigits(base)) { + base = generateRandomNumber(BASE_LENGTH); + } + + return `${base}${calculateRenavamCheckDigit(base)}`; +}; diff --git a/src/generate-voter-id/generate-voter-id.test.ts b/src/generate-voter-id/generate-voter-id.test.ts index cead4fe4..3d589cd8 100644 --- a/src/generate-voter-id/generate-voter-id.test.ts +++ b/src/generate-voter-id/generate-voter-id.test.ts @@ -1,6 +1,7 @@ import * as fc from "fast-check"; import { type StateCode } from "../_internals/constants/states"; +import { PROTOTYPE_KEYS } from "../_internals/test/arbitraries"; import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; import { UF_TO_VOTER_ID_CODE } from "../is-valid-voter-id/constants"; import { isValidVoterId } from "../is-valid-voter-id/is-valid-voter-id"; @@ -26,9 +27,35 @@ describe("generateVoterId", () => { expect(isValidVoterId(voterId)).toBe(true); }); + it("should fall back to the default UF instead of reaching the prototype chain for a state", () => { + for (const key of PROTOTYPE_KEYS) { + // @ts-expect-error: intentionally invalid input + const voterId = generateVoterId(key); + + expect(voterId).toMatch(/^\d{12}$/); + expect(voterId.slice(8, 10)).toBe("28"); + expect(isValidVoterId(voterId)).toBe(true); + } + }); + + it("should fall back to the default UF instead of throwing for a state that is not a string", () => { + const nullPrototype = generateVoterId(Object.create(null)); + const throwing = generateVoterId({ + toString() { + throw new Error("no string conversion"); + }, + } as unknown as StateCode); + + expect(nullPrototype.slice(8, 10)).toBe("28"); + expect(throwing.slice(8, 10)).toBe("28"); + expect(isValidVoterId(nullPrototype)).toBe(true); + expect(isValidVoterId(throwing)).toBe(true); + }); + describe("properties", () => { const states = Object.keys(UF_TO_VOTER_ID_CODE) as (StateCode | "ZZ")[]; const stateCode = fc.constantFrom(...states); + const hostileStateCode = fc.oneof(fc.constantFrom(...PROTOTYPE_KEYS), fc.anything()); test("should carry the federative union code of every state it supports", () => { fc.assert( @@ -41,6 +68,17 @@ describe("generateVoterId", () => { }), ); }); + + test("should generate a valid voter id for any state at all, prototype chain keys included", () => { + fc.assert( + fc.property(hostileStateCode, (state) => { + const voterId = generateVoterId(state as StateCode); + + expect(voterId).toMatch(/^\d{12}$/); + expect(isValidVoterId(voterId)).toBe(true); + }), + ); + }); }); }); diff --git a/src/generate-voter-id/generate-voter-id.ts b/src/generate-voter-id/generate-voter-id.ts index 57102d97..8dbb1a96 100644 --- a/src/generate-voter-id/generate-voter-id.ts +++ b/src/generate-voter-id/generate-voter-id.ts @@ -4,13 +4,31 @@ import { type StateCode } from "../_internals/constants/states"; import { generateRandomNumber } from "../_internals/generate-random-number/generate-random-number"; import { UF_TO_VOTER_ID_CODE } from "../is-valid-voter-id/constants"; +export type { StateCode } from "../_internals/constants/states"; + +/** + * The federative union code of a state, `"ZZ"`'s own code for anything else. The lookup is an own + * property one, so a key of the prototype chain (`"__proto__"`, `"constructor"`, `"toString"`) + * resolves as an unknown state instead of reaching `Object.prototype` and handing a function or an + * object to the check digit calculation. + * + * @param {StateCode | "ZZ"} state - The state the voter id is generated for. + * @returns {string} The two digit federative union code of that state, or `"ZZ"`'s own code. + */ +const getFederativeUnion = (state: StateCode | "ZZ"): string => + typeof state === "string" && Object.hasOwn(UF_TO_VOTER_ID_CODE, state) + ? UF_TO_VOTER_ID_CODE[state] + : UF_TO_VOTER_ID_CODE.ZZ; + /** * Generates a valid random Brazilian voter id (título de eleitor). * * Uses `Math.random()` internally, so it is not cryptographically secure, do not use for security purposes. * * @param {StateCode | "ZZ"} state - Optional. The Brazilian state code to generate a voter id - * for, or `"ZZ"` for a voter id issued abroad. Defaults to `"ZZ"` when omitted or unknown. + * for, or `"ZZ"` for a voter id issued abroad. Defaults to `"ZZ"` when omitted or unknown, a key + * of the prototype chain (`"__proto__"`, `"constructor"`) and a value that is not a string + * included, so a malformed state never throws. * @returns {string} A valid 12-digit voter id string without formatting. * * @example @@ -24,6 +42,9 @@ import { UF_TO_VOTER_ID_CODE } from "../is-valid-voter-id/constants"; * the two-step módulo 11 structure; the weights themselves are not published by the TSE and follow * the community reference cited as `Based on:`. * + * The TSE resolution page sits behind a bot filter and answers HTTP 403 to every non-browser + * client, so it has to be opened in a browser. + * * @see Official: https://www.tse.jus.br/legislacao/compilada/res/2021/resolucao-no-23-659-de-26-de-outubro-de-2021 * @see Based on: https://siga0984.wordpress.com/2019/05/01/algoritmos-validacao-de-titulo-de-eleitor/ */ @@ -31,7 +52,7 @@ export const generateVoterId = ( // Stryker disable next-line StringLiteral: any default other than a valid key still falls through the ?? UF_TO_VOTER_ID_CODE.ZZ lookup below, so the literal default value is unobservable. state: StateCode | "ZZ" = "ZZ", ): string => { - const federativeUnion = UF_TO_VOTER_ID_CODE[state] ?? UF_TO_VOTER_ID_CODE.ZZ; + const federativeUnion = getFederativeUnion(state); const sequentialNumber = generateRandomNumber(8); const digit1 = calculateVoterIdFirstDigit({ sequentialNumber, federativeUnion }); const digit2 = calculateVoterIdSecondDigit({ federativeUnion, firstDigit: digit1 }); diff --git a/src/get-address-info-by-cep/get-address-info-by-cep.test.ts b/src/get-address-info-by-cep/get-address-info-by-cep.test.ts index 1bf2a67a..14e7396a 100644 --- a/src/get-address-info-by-cep/get-address-info-by-cep.test.ts +++ b/src/get-address-info-by-cep/get-address-info-by-cep.test.ts @@ -272,6 +272,8 @@ describe("getAddressInfoByCep", () => { const requestedUrls = fetchMock.mock.calls.map(([input]: [FetchInput]) => requestUrl(input), ); + expect(requestedUrls.some((url: string) => url.includes("viacep.com.br"))).toBe(true); + expect(requestedUrls.some((url: string) => url.includes("brasilapi.com.br"))).toBe(true); expect(requestedUrls.some((url: string) => url.includes("widenet"))).toBe(false); }); @@ -302,6 +304,24 @@ describe("getAddressInfoByCep", () => { ).rejects.toThrow("Nenhum provedor válido especificado"); }); + it("should throw GetAddressInfoByCepValidationError for a providers value that is not an array, null included", async () => { + await Promise.all( + [null, "viacep", 5, {}, true].map((providers) => + expect( + // @ts-expect-error: intentionally invalid input + getAddressInfoByCep(VALID_CEP, { providers }), + ).rejects.toThrow(GetAddressInfoByCepValidationError), + ), + ); + }); + + it("should include the Portuguese message for a providers value that is not an array", async () => { + await expect( + // @ts-expect-error: intentionally invalid input + getAddressInfoByCep(VALID_CEP, { providers: null }), + ).rejects.toThrow("Nenhum provedor válido especificado"); + }); + it("should use only specified providers", async () => { const result = await getAddressInfoByCep(VALID_CEP, { providers: ["brasilapi"], @@ -512,6 +532,26 @@ describe("getAddressInfoByCep", () => { ); }); + it("should throw GetAddressInfoByCepNotFoundError when BrasilAPI answers 404, the status it reports an unknown CEP with", async () => { + setupFetchMock(fetchMock, { + brasilapi: createJsonResponse({ errors: [{ message: "CEP não encontrado" }] }, 404), + }); + + await expect(getAddressInfoByCep(VALID_CEP, { providers: ["brasilapi"] })).rejects.toThrow( + GetAddressInfoByCepNotFoundError, + ); + }); + + it("should throw GetAddressInfoByCepServiceError when BrasilAPI answers a non-404 error status", async () => { + setupFetchMock(fetchMock, { + brasilapi: createJsonResponse({}, 500), + }); + + await expect(getAddressInfoByCep(VALID_CEP, { providers: ["brasilapi"] })).rejects.toThrow( + GetAddressInfoByCepServiceError, + ); + }); + it("should throw GetAddressInfoByCepNotFoundError when every provider returns a payload that is not an object", async () => { setupFetchMock(fetchMock, { brasilapi: createJsonResponse(null), @@ -717,17 +757,19 @@ describe("getAddressInfoByCep", () => { LIVE_TEST_TIMEOUT, ); - it( - "should fetch live address from Widenet", - async () => { - const result = await getAddressInfoByCep(VALID_CEP, { - providers: ["widenet"], - }); - - expectAddressFound(result); - }, - LIVE_TEST_TIMEOUT, - ); + describe.skip("Widenet, skipped while the service answers HTTP 502 (since September 2026, the reason it left the default provider list)", () => { + it( + "should fetch live address from Widenet", + async () => { + const result = await getAddressInfoByCep(VALID_CEP, { + providers: ["widenet"], + }); + + expectAddressFound(result); + }, + LIVE_TEST_TIMEOUT, + ); + }); it( "should fetch live address from BrasilAPI", diff --git a/src/get-address-info-by-cep/get-address-info-by-cep.ts b/src/get-address-info-by-cep/get-address-info-by-cep.ts index 21508aa6..39489e34 100644 --- a/src/get-address-info-by-cep/get-address-info-by-cep.ts +++ b/src/get-address-info-by-cep/get-address-info-by-cep.ts @@ -53,12 +53,26 @@ export type CepProvider = "viacep" | "widenet" | "brasilapi"; /** Options of `getAddressInfoByCep`. */ export type GetAddressInfoByCepOptions = { - /** Which CEP services to race, in the order given (default: all of them). */ + /** + * Which CEP services to race, in the order given (default: `["viacep", "brasilapi"]`; the + * deprecated `"widenet"` provider is excluded from the default list, but can still be + * requested explicitly). + */ providers?: CepProvider[]; }; type ProviderPayload = Record; +/** + * The status BrasilAPI answers an unknown CEP with, alongside an `errors` body. ViaCEP and + * Widenet report a miss inside a 200 body instead, so BrasilAPI is the only provider whose + * not-found signal is an HTTP status and the only one that needs it mapped before `response.ok` + * turns it into a service failure. + * + * @see Based on: https://brasilapi.com.br/docs#tag/CEP + */ +const BRASIL_API_NOT_FOUND_STATUS = 404; + const asString = (value: unknown): string => (typeof value === "string" ? value : ""); const readPayload = async (response: Response): Promise => { @@ -126,6 +140,12 @@ const fetchWidenet = async (cep: string): Promise => { const fetchBrasilApi = async (cep: string): Promise => { const response = await fetchWithRetry(`https://brasilapi.com.br/api/cep/v1/${cep}`); + if (response.status === BRASIL_API_NOT_FOUND_STATUS) { + // Stryker disable next-line StringLiteral: only `instanceof GetAddressInfoByCepNotFoundError` + // is checked when aggregating provider failures below, so this message is never observable. + throw new GetAddressInfoByCepNotFoundError("CEP não encontrado"); + } + if (!response.ok) { // Stryker disable next-line StringLiteral: only `instanceof GetAddressInfoByCepNotFoundError` // is checked when aggregating provider failures below, so this message is never observable. @@ -160,19 +180,27 @@ const providerMap: Record Promise> = * Fetches address information for a given CEP using multiple providers simultaneously. * Returns the result from the first provider that responds successfully. * + * The providers are started together and raced with `Promise.any`, not tried one after the + * other, so a provider that is retrying delays nothing for the others: its retries only push + * back the moment its own failure lands, and therefore the moment an all-failed rejection can + * surface. + * * @param {string|number} cep - The CEP (Brazilian postal code) to search for. Can be a string or number. * @param {GetAddressInfoByCepOptions} options - Optional configuration for the function. * @param {CepProvider[]} options.providers - List of providers to use. Defaults to `["viacep", "brasilapi"]` * if not specified (the deprecated `"widenet"` provider is excluded from the default list, but can still * be requested explicitly). * @returns {Promise} A promise that resolves to the address information. - * @throws {GetAddressInfoByCepValidationError} If the CEP format is invalid. + * @throws {GetAddressInfoByCepValidationError} If the CEP format is invalid, or if + * `options.providers` is given and names no known provider: an empty array, an array of unknown + * names, and a value that is not an array at all (`null` included) all reject this way rather + * than with a raw `TypeError`. * @throws {GetAddressInfoByCepNotFoundError} If the CEP is not found in any of the services. * @throws {GetAddressInfoByCepServiceError} If all services are unavailable. * * @example * ```typescript - * // Using all providers (default) + * // Using the default providers (["viacep", "brasilapi"]) * const address = await getAddressInfoByCep("01310100"); * * // Using specific providers @@ -185,8 +213,10 @@ const providerMap: Record Promise> = * ``` * * @see Official: https://www.correios.com.br/enviar/precisa-de-ajuda/tudo-sobre-cep - * @see Official: https://viacep.com.br/ Default `"viacep"` provider. - * @see Official: https://brasilapi.com.br/docs#tag/CEP Default `"brasilapi"` provider. + * @see Based on: https://viacep.com.br/ + * ViaCEP, one of the two default providers. A third-party service, not a Correios one. + * @see Based on: https://brasilapi.com.br/docs#tag/CEP + * BrasilAPI, the other default provider. A third-party service, not a Correios one. */ export const getAddressInfoByCep = async ( cep: string | number, @@ -206,14 +236,16 @@ export const getAddressInfoByCep = async ( let providersToUse: CepProvider[]; if (options?.providers === undefined) { - providersToUse = ["viacep", "brasilapi"] as CepProvider[]; - } else { + providersToUse = ["viacep", "brasilapi"]; + } else if (Array.isArray(options.providers)) { // An empty `options.providers` array also filters down to an empty `providersToUse` below, // which already reports the same validation error, so there is no dedicated check for it here. - providersToUse = options.providers.filter((p) => Object.hasOwn(providerMap, p)); + providersToUse = options.providers.filter((provider) => Object.hasOwn(providerMap, provider)); if (providersToUse.length === 0) { throw new GetAddressInfoByCepValidationError("Nenhum provedor válido especificado"); } + } else { + throw new GetAddressInfoByCepValidationError("Nenhum provedor válido especificado"); } let notFound = false; diff --git a/src/get-area-code-info/get-area-code-info.test.ts b/src/get-area-code-info/get-area-code-info.test.ts index a1b1ce1e..cc18dfeb 100644 --- a/src/get-area-code-info/get-area-code-info.test.ts +++ b/src/get-area-code-info/get-area-code-info.test.ts @@ -14,7 +14,8 @@ describe("getAreaCodeInfo", () => { areaCode: 11, stateCode: "SP", stateName: "São Paulo", - region: "Sudeste", + regionCode: "SE", + regionName: "Sudeste", stateCodes: ["SP"], }); }); @@ -24,7 +25,8 @@ describe("getAreaCodeInfo", () => { areaCode: 11, stateCode: "SP", stateName: "São Paulo", - region: "Sudeste", + regionCode: "SE", + regionName: "Sudeste", stateCodes: ["SP"], }); }); @@ -38,7 +40,8 @@ describe("getAreaCodeInfo", () => { areaCode: 68, stateCode: "AC", stateName: "Acre", - region: "Norte", + regionCode: "N", + regionName: "Norte", stateCodes: ["AC"], }); }); @@ -48,7 +51,8 @@ describe("getAreaCodeInfo", () => { areaCode: 61, stateCode: "DF", stateName: "Distrito Federal", - region: "Centro-Oeste", + regionCode: "CO", + regionName: "Centro-Oeste", stateCodes: ["DF", "GO"], }); }); @@ -178,7 +182,8 @@ describe("getAreaCodeInfo types", () => { areaCode: number; stateCode: StateCode; stateName: StateName; - region: "Norte" | "Nordeste" | "Centro-Oeste" | "Sudeste" | "Sul"; + regionCode: "N" | "NE" | "CO" | "SE" | "S"; + regionName: "Norte" | "Nordeste" | "Centro-Oeste" | "Sudeste" | "Sul"; stateCodes: StateCode[]; }>(); }); diff --git a/src/get-area-code-info/get-area-code-info.ts b/src/get-area-code-info/get-area-code-info.ts index aa2440ee..f3ef2b33 100644 --- a/src/get-area-code-info/get-area-code-info.ts +++ b/src/get-area-code-info/get-area-code-info.ts @@ -3,6 +3,8 @@ import { DATA, type State, type StateCode, type StateName } from "../_internals/ import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +export type { State, StateCode, StateName } from "../_internals/constants/states"; + /** The state, and the region it belongs to, that `getAreaCodeInfo` returns for a DDD. */ export type AreaCodeInfo = { /** The DDD (area code) as a number, e.g. `11`. */ @@ -11,8 +13,10 @@ export type AreaCodeInfo = { stateCode: StateCode; /** The full name of the state the DDD belongs to, e.g. `"São Paulo"`. */ stateName: StateName; + /** The code of the region the state belongs to, e.g. `"SE"`. */ + regionCode: State["regionCode"]; /** The full name of the region the state belongs to, e.g. `"Sudeste"`. */ - region: State["regionName"]; + regionName: State["regionName"]; /** * Every state the DDD serves, the primary `stateCode` first, e.g. `["SP"]` for 11 and * `["DF", "GO"]` for 61. @@ -23,12 +27,15 @@ export type AreaCodeInfo = { /** * Retrieves the state (and its region) a Brazilian DDD (area code) belongs to. * - * `stateCode` is always a single state: the one that holds all but a handful of the DDD's + * `stateCode` is always a single state: the one the DDD is seated in, the state of the city the + * code was allocated around, which is not necessarily the state holding most of its * municipalities. Four DDDs straddle a state border, and for those `stateCodes` lists the * other states too. DDD 61 is the widest of them, serving the Distrito Federal and the twelve * Goiás municipalities of the Entorno do Distrito Federal, so its `stateCode` is `"DF"` and - * its `stateCodes` is `["DF", "GO"]`. The other three are 42 (`["PR", "SC"]`, for Porto - * União), 47 (`["SC", "PR"]`, for Rio Negro) and 49 (`["SC", "PR"]`, for Barracão). + * its `stateCodes` is `["DF", "GO"]` even though the Distrito Federal holds only one of its + * thirteen municipalities, Brasília. The other three are 42 (`["PR", "SC"]`, for Porto + * União), 47 (`["SC", "PR"]`, for Rio Negro) and 49 (`["SC", "PR"]`, for Barracão), and there + * the seat does hold every municipality but the one named. * * A `areaCode` given as a number must be a non-negative integer: a sign and a decimal point * are not digits, so `-11` and `1.1` are rejected instead of being read as `11`. @@ -39,19 +46,26 @@ export type AreaCodeInfo = { * 67 DDDs in use under the Plano Geral de Numeração. * * Resolução Anatel nº 749/2022, art. 15, defines the Código Nacional (area code); the gov.br - * page below lists the codes actually allocated and links to the Anexo of Resolução Anatel - * nº 263/2001, which gives the Código Nacional of every municipality. + * page below lists the codes actually allocated and links, under "POR MUNICÍPIO", to the Anexo + * of Resolução Anatel nº 263/2001, which gives the Código Nacional of every municipality. * * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749 * @see Official: https://www.gov.br/anatel/pt-br/regulado/numeracao/codigos-nacionais - * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2001/383-resolucao-263 + * @see Based on: https://informacoes.anatel.gov.br/legislacao/resolucoes/2001/383-resolucao-263 + * Anexo of Resolução nº 263/2001 (revoked; still the table Anatel's Códigos Nacionais page links to). * @see Based on: https://brasilapi.com.br/docs#tag/DDD * * @example * ```typescript - * getAreaCodeInfo("11"); // { areaCode: 11, stateCode: "SP", stateName: "São Paulo", region: "Sudeste", stateCodes: ["SP"] } - * getAreaCodeInfo(21); // { areaCode: 21, stateCode: "RJ", stateName: "Rio de Janeiro", region: "Sudeste", stateCodes: ["RJ"] } - * getAreaCodeInfo("61"); // { areaCode: 61, stateCode: "DF", stateName: "Distrito Federal", region: "Centro-Oeste", stateCodes: ["DF", "GO"] } + * getAreaCodeInfo("11"); + * // { areaCode: 11, stateCode: "SP", stateName: "São Paulo", regionCode: "SE", regionName: "Sudeste", stateCodes: ["SP"] } + * + * getAreaCodeInfo(21); + * // { areaCode: 21, stateCode: "RJ", stateName: "Rio de Janeiro", regionCode: "SE", regionName: "Sudeste", stateCodes: ["RJ"] } + * + * getAreaCodeInfo("61"); + * // { areaCode: 61, stateCode: "DF", stateName: "Distrito Federal", regionCode: "CO", regionName: "Centro-Oeste", stateCodes: ["DF", "GO"] } + * * getAreaCodeInfo("00"); // null * getAreaCodeInfo(-11); // null * ``` @@ -76,7 +90,8 @@ export const getAreaCodeInfo = (areaCode: string | number): AreaCodeInfo | null areaCode: numericAreaCode, stateCode, stateName: state.name, - region: state.regionName, + regionCode: state.regionCode, + regionName: state.regionName, stateCodes: [stateCode, ...(AREA_CODE_SECONDARY_STATES[numericAreaCode] ?? [])], }; }; diff --git a/src/get-area-codes-by-state/get-area-codes-by-state.ts b/src/get-area-codes-by-state/get-area-codes-by-state.ts index 04a8f88c..5bcd2349 100644 --- a/src/get-area-codes-by-state/get-area-codes-by-state.ts +++ b/src/get-area-codes-by-state/get-area-codes-by-state.ts @@ -27,13 +27,14 @@ import { AREA_CODE_SECONDARY_STATES, AREA_CODE_STATES } from "../_internals/cons * getAreaCodesByState("XX"); // [] * ``` * - * Resolução Anatel nº 749/2022, art. 15, defines the Código Nacional (area code); the gov.br - * page below lists the codes actually allocated and links to the Anexo of Resolução Anatel - * nº 263/2001, which gives the Código Nacional of every municipality. + * Resolução Anatel nº 749/2022, art. 15, defines the Código Nacional (area code). The Anexo the + * gov.br page below links to, giving the Código Nacional of every municipality, is the one this + * inverse lookup was derived from and is no longer in force. * * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749 * @see Official: https://www.gov.br/anatel/pt-br/regulado/numeracao/codigos-nacionais - * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2001/383-resolucao-263 + * @see Based on: https://informacoes.anatel.gov.br/legislacao/resolucoes/2001/383-resolucao-263 + * Anexo of Resolução nº 263/2001, revoked, and still the table Anatel's page links to. */ export const getAreaCodesByState = (stateCode: string): number[] => { if (typeof stateCode !== "string") return []; diff --git a/src/get-bank-by-code/get-bank-by-code.ts b/src/get-bank-by-code/get-bank-by-code.ts index add89935..199680db 100644 --- a/src/get-bank-by-code/get-bank-by-code.ts +++ b/src/get-bank-by-code/get-bank-by-code.ts @@ -2,6 +2,8 @@ import { BANKS, type Bank } from "../_internals/constants/banks"; import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +export type { Bank } from "../_internals/constants/banks"; + const CODE_LENGTH = 3; /** @@ -19,8 +21,8 @@ const CODE_LENGTH = 3; * ``` * * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/str1/ParticipantesSTR.csv - * @see Official: https://brasilapi.com.br/api/banks/v1 Fallback source used by the dataset - * generator (`scripts/banks.ts`) when the Bacen CSV request fails. + * @see Based on: https://brasilapi.com.br/api/banks/v1 + * Fallback source used by the dataset generator (`scripts/banks.ts`) when the Bacen CSV request fails. */ export const getBankByCode = (code: string | number): Bank | null => { if (!isLookupCode(code)) return null; diff --git a/src/get-bank-by-ispb/get-bank-by-ispb.ts b/src/get-bank-by-ispb/get-bank-by-ispb.ts index cefd3156..2fc3d7c8 100644 --- a/src/get-bank-by-ispb/get-bank-by-ispb.ts +++ b/src/get-bank-by-ispb/get-bank-by-ispb.ts @@ -2,6 +2,8 @@ import { BANKS, type Bank } from "../_internals/constants/banks"; import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +export type { Bank } from "../_internals/constants/banks"; + const ISPB_LENGTH = 8; /** @@ -23,8 +25,8 @@ const ISPB_LENGTH = 8; * ``` * * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/str1/ParticipantesSTR.csv - * @see Official: https://brasilapi.com.br/api/banks/v1 Fallback source used by the dataset - * generator (`scripts/banks.ts`) when the Bacen CSV request fails. + * @see Based on: https://brasilapi.com.br/api/banks/v1 + * Fallback source used by the dataset generator (`scripts/banks.ts`) when the Bacen CSV request fails. */ export const getBankByIspb = (value: string | number): Bank | null => { if (!isLookupCode(value)) return null; diff --git a/src/get-banks/get-banks.ts b/src/get-banks/get-banks.ts index 345f19cf..a8b4e577 100644 --- a/src/get-banks/get-banks.ts +++ b/src/get-banks/get-banks.ts @@ -1,5 +1,7 @@ import { BANKS, type Bank } from "../_internals/constants/banks"; +export type { Bank } from "../_internals/constants/banks"; + /** * Returns every Brazilian bank with a compensation code (COMPE), published by Banco Central * do Brasil in the STR (Sistema de Transferência de Reservas) participants list. @@ -15,7 +17,7 @@ import { BANKS, type Bank } from "../_internals/constants/banks"; * ``` * * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/str1/ParticipantesSTR.csv - * @see Official: https://brasilapi.com.br/api/banks/v1 Fallback source used by the dataset - * generator (`scripts/banks.ts`) when the Bacen CSV request fails. + * @see Based on: https://brasilapi.com.br/api/banks/v1 + * Fallback source used by the dataset generator (`scripts/banks.ts`) when the Bacen CSV request fails. */ export const getBanks = (): Bank[] => BANKS.map((bank) => Object.assign({}, bank)); diff --git a/src/get-boleto-info/constants.ts b/src/get-boleto-info/constants.ts index 57378edf..76aabacc 100644 --- a/src/get-boleto-info/constants.ts +++ b/src/get-boleto-info/constants.ts @@ -1,11 +1,23 @@ /** - * The "fator de vencimento" (expiration factor) counts days since a FEBRABAN base date and - * cycles every `CYCLE_LENGTH` days once it reaches its 4-digit maximum. FEBRABAN Comunicado - * FB-009/2023 sets the current cycle boundary: the factor reached its maximum, 9999, on - * 21/02/2025 and restarted at 1000 on 22/02/2025. + * The "fator de vencimento" (expiration factor) counts days since the base date 07/10/1997 that + * Carta-Circular BCB nº 2.926/2000 places in positions 6-9 of the barcode, and cycles every + * `CYCLE_LENGTH` days once it reaches its 4-digit maximum: it reached 9999 on 21/02/2025 and + * restarted at 1000 on 22/02/2025. FEBRABAN announced that reset in Comunicado FB-009/2023, + * which is not published on FEBRABAN's public site; the Bradesco cobrança layout manual below + * reproduces the rule and its correlation table. * + * `RANGE_BEFORE` and `RANGE_AFTER` are a heuristic of this library, not a published rule. + * Neither FEBRABAN nor the Banco Central publishes any way of telling an old cycle factor from + * a new cycle one, so every factor resolves to either of two dates `CYCLE_LENGTH` days apart. + * These two windows pick between them, which means the date a factor resolves to depends on the + * `referenceDate` given to `getBoletoInfo` and can change as that reference moves. + * + * @see Official: https://www.bcb.gov.br/pre/normativos/c_circ/2000/pdf/c_circ_2926_v1_O.pdf * @see Official: https://cmsarquivos.febraban.org.br/Arquivos/documentos/PDF/Layout%20-%20C%C3%B3digo%20de%20Barras%20-%20Vers%C3%A3o%208%20-%2011_05_2026.pdf * @see Official: https://portal.febraban.org.br/pagina/3425/33/pt-br/layout-febraban + * @see Based on: https://banco.bradesco/assets/pessoajuridica/pdf/4008-524-0121-layout-cobranca-versao-portugues.pdf + * Bradesco "Layout da Cobrança" manual: base date 07/10/1997, 03/07/2000 = 1000, 21/02/2025 = 9999 + * and a restart at 1000 on 22/02/2025. */ export const DAY_IN_MS = 86_400_000; @@ -15,6 +27,15 @@ export const BASE_DATE_DAY = 7; export const CYCLE_LENGTH = 9000; +/** + * The earliest cycle the two candidate search may consider. The factor only started carrying + * 1000 on 03/07/2000, so the base date is the oldest day the field can denote: a negative cycle + * would place a factor before 07/10/1997, a date no fator de vencimento can express (and one the + * same function already refuses to read out of a literal factor below `MIN_FACTOR`). A + * `referenceDate` early enough to make the arithmetic yield a negative cycle is clamped here. + */ +export const FIRST_CYCLE = 0; + export const MIN_FACTOR = 1000; export const RANGE_BEFORE = 3000; diff --git a/src/get-boleto-info/get-boleto-info.test.ts b/src/get-boleto-info/get-boleto-info.test.ts index adf592b7..b4fca484 100644 --- a/src/get-boleto-info/get-boleto-info.test.ts +++ b/src/get-boleto-info/get-boleto-info.test.ts @@ -11,41 +11,58 @@ const withFactor = { "1000": "00190000090114971860168524522114210000000102656", "1001": "00190000090114971860168524522114810010000102656", "5000": "00190000090114971860168524522114350000000102656", + "7000": "00190000090114971860168524522114970000000102656", "7586": "00190000090114971860168524522114675860000102656", "7654": "00190000090114971860168524522114576540000102656", + "8841": "00190000090114971860168524522114488410000102656", "8999": "00190000090114971860168524522114489990000102656", "9999": "00190000090114971860168524522114799990000102656", }; +const REFERENCE_DATE = new Date(2025, 5, 15); + +const CANONICAL_INFO = { + amount: 102_656, + expirationDate: new Date(2018, 6, 15), + bankCode: "001", +}; + const ARRECADACAO_LINE = "846100000005246100291102005460339004695895061080"; const ARRECADACAO_BARCODE = "84610000000246100291100054603390069589506108"; describe("getBoletoInfo", () => { - describe("should return undefined", () => { + describe("should return null", () => { test("when boleto is empty string", () => { - expect(getBoletoInfo("")).toBeUndefined(); + expect(getBoletoInfo("")).toBeNull(); }); test("when boleto is invalid", () => { - expect(getBoletoInfo("00190000090114971860168524522114775860000102656")).toBeUndefined(); + expect(getBoletoInfo("00190000090114971860168524522114775860000102656")).toBeNull(); + }); + + test("when boleto is not a string, never undefined, as every other getter answers", () => { + // @ts-expect-error: intentionally invalid input + expect(getBoletoInfo(null)).toBeNull(); + // @ts-expect-error: intentionally invalid input + expect(getBoletoInfo()).toBeNull(); + // @ts-expect-error: intentionally invalid input + expect(getBoletoInfo(123)).toBeNull(); }); }); describe("should return boleto info", () => { test("when boleto is valid without mask", () => { - expect(getBoletoInfo("00190000090114971860168524522114675860000102656")).toStrictEqual({ - amount: 102_656, - expirationDate: new Date(2018, 6, 15), - bankCode: "001", - }); + const info = getBoletoInfo(withFactor["7586"], { referenceDate: REFERENCE_DATE }); + + expect(info).toStrictEqual(CANONICAL_INFO); }); test("when boleto is valid with mask", () => { - expect(getBoletoInfo("0019000009 01149.718601 68524.522114 6 75860000102656")).toStrictEqual({ - amount: 102_656, - expirationDate: new Date(2018, 6, 15), - bankCode: "001", - }); + const masked = "0019000009 01149.718601 68524.522114 6 75860000102656"; + + expect(getBoletoInfo(masked, { referenceDate: REFERENCE_DATE })).toStrictEqual( + CANONICAL_INFO, + ); }); test("when the amount field is all zeros (same fixture as the 'valid without mask' boleto, amount positions 37-46 zeroed and the main check digit recalculated)", () => { @@ -54,7 +71,7 @@ describe("getBoletoInfo", () => { }); describe("fator de vencimento (fixtures share a banco 001, R$ 1.026,56 slip with only the factor and check digits changed; FEBRABAN restarted the factor at 1000 on 22/02/2025 right after it reached 9999 on 21/02/2025, so the same factor can map to two dates 9000 days apart, and referenceDate pins which cycle wins)", () => { - const referenceDate = new Date(2025, 5, 15); + const referenceDate = REFERENCE_DATE; test("should return null when there is no fator de vencimento", () => { expect(getBoletoInfo(withFactor["0000"], { referenceDate })?.expirationDate).toBeNull(); @@ -103,7 +120,7 @@ describe("getBoletoInfo", () => { ).toStrictEqual(new Date(2000, 6, 3)); }); - test("should resolve a factor inside the safety range to its closest candidate (fixture '7586' with the factor changed to 6614 and the main check digit recalculated: with referenceDate 15/06/2025 neither cycle candidate falls inside the accepted control range, landing in the 'range de segurança' the FEBRABAN manual describes, so the closest one is used anyway)", () => { + test("should resolve a factor inside the safety range to its closest candidate (fixture '7586' with the factor changed to 6614 and the main check digit recalculated: with referenceDate 15/06/2025 neither cycle candidate falls inside the accepted control range, landing in the safety window RANGE_BEFORE/RANGE_AFTER define, which is a heuristic of this library rather than a published FEBRABAN rule, so the closest one is used anyway)", () => { expect( getBoletoInfo("00190000090114971860168524522114466140000102656", { referenceDate, @@ -111,12 +128,38 @@ describe("getBoletoInfo", () => { ).toStrictEqual(new Date(2015, 10, 16)); }); - test("should accept a factor whose difference from the reference date is exactly RANGE_AFTER (5500 days), even though the other cycle candidate (3500 days before the reference, on the other side) is numerically closer", () => { + test("should prefer the candidate inside the control range over the closest one (factor 1000 with referenceDate 16/06/2011: the old cycle date 03/07/2000 is 4000 days back, past RANGE_BEFORE, while the new cycle date 22/02/2025 is 5000 days ahead, inside RANGE_AFTER)", () => { + expect( + getBoletoInfo(withFactor["1000"], { referenceDate: new Date(2011, 5, 16) })?.expirationDate, + ).toStrictEqual(new Date(2025, 1, 22)); + }); + + test("should accept a factor whose difference from the reference date is exactly RANGE_AFTER (5500 days)", () => { expect( getBoletoInfo(withFactor["1000"], { referenceDate: new Date(1985, 5, 12) })?.expirationDate, ).toStrictEqual(new Date(2000, 6, 3)); }); + test("should never resolve a factor to a date before the 07/10/1997 base date, even when the reference date predates the scheme: the cycle search is clamped to the first cycle, so each factor below gives the single date it is able to denote", () => { + const preSchemeReference = new Date(2000, 0, 1); + + expect( + getBoletoInfo(withFactor["7000"], { referenceDate: preSchemeReference })?.expirationDate, + ).toStrictEqual(new Date(2016, 11, 6)); + expect( + getBoletoInfo(withFactor["8841"], { referenceDate: preSchemeReference })?.expirationDate, + ).toStrictEqual(new Date(2021, 11, 21)); + expect( + getBoletoInfo(withFactor["9999"], { referenceDate: preSchemeReference })?.expirationDate, + ).toStrictEqual(new Date(2025, 1, 21)); + }); + + test("should keep the clamped answer stable while the reference date is still before the first cycle", () => { + expect( + getBoletoInfo(withFactor["8841"], { referenceDate: new Date(2003, 0, 1) })?.expirationDate, + ).toStrictEqual(new Date(2021, 11, 21)); + }); + test("should default the reference date to now", () => { const now = new Date(); @@ -190,17 +233,17 @@ describe("getBoletoInfo", () => { test("should return a value exactly when the bank slip is valid", () => { fc.assert( fc.property(fc.string(), (value) => { - expect(getBoletoInfo(value) !== undefined).toBe(isValidBoleto(value)); + expect(getBoletoInfo(value) !== null).toBe(isValidBoleto(value)); }), ); }); - test("should never throw and always return an object or undefined", () => { + test("should never throw and always return an object or null", () => { fc.assert( fc.property(fc.anything(), (value) => { const info = getBoletoInfo(value as string); - expect(info === undefined || typeof info === "object").toBe(true); + expect(info === null || typeof info === "object").toBe(true); }), ); }); @@ -208,10 +251,10 @@ describe("getBoletoInfo", () => { }); describe("getBoletoInfo types", () => { - test("should take a string, optional options, and return boleto info or undefined", () => { + test("should take a string, optional options, and return boleto info or null", () => { expectTypeOf(getBoletoInfo).parameter(0).toEqualTypeOf(); expectTypeOf(getBoletoInfo).parameter(1).toEqualTypeOf(); - expectTypeOf(getBoletoInfo).returns.toEqualTypeOf(); + expectTypeOf(getBoletoInfo).returns.toEqualTypeOf(); }); test("should restrict referenceDate to a Date", () => { diff --git a/src/get-boleto-info/get-boleto-info.ts b/src/get-boleto-info/get-boleto-info.ts index 6592c39f..e0029686 100644 --- a/src/get-boleto-info/get-boleto-info.ts +++ b/src/get-boleto-info/get-boleto-info.ts @@ -7,6 +7,7 @@ import { BASE_DATE_YEAR, CYCLE_LENGTH, DAY_IN_MS, + FIRST_CYCLE, MIN_FACTOR, RANGE_AFTER, RANGE_BEFORE, @@ -43,7 +44,10 @@ const getExpirationDate = (factor: number, referenceDate: Date): Date | null => if (!Number.isFinite(factor) || factor < MIN_FACTOR) return null; const reference = toDayNumber(referenceDate); - const cycle = Math.floor((reference - getBaseDayNumber() - factor) / CYCLE_LENGTH); + const cycle = Math.max( + FIRST_CYCLE, + Math.floor((reference - getBaseDayNumber() - factor) / CYCLE_LENGTH), + ); let closest = 0; let closestDistance = Number.POSITIVE_INFINITY; @@ -79,39 +83,60 @@ export type GetBoletoInfoOptions = { /** * Extracts information from a Brazilian bank slip (boleto). * + * The value is checked with `isValidBoleto` first, so an invalid bank slip gives `null` rather + * than a partial result, the way every other getter of this package answers a lookup it cannot + * resolve (`getFormatLicensePlate`, `getMunicipality`). + * * Supports the 47 digit "cobrança bancária" linha digitável and, additionally, the * "arrecadação" (convênio/tributos) bank slip: 48 digit linha digitável or 44 digit * barcode, both starting with `8`. Arrecadação bank slips also return `type`, `segment`, - * `value` and `hasEffectiveValue`, and have no `bankCode` nor `expirationDate`. + * `value` and `hasEffectiveValue`, and, carrying neither a bank code nor a fator de vencimento, + * come back with `bankCode` set to `""` and `expirationDate` set to `null` rather than with those + * two keys missing. + * + * Neither FEBRABAN nor the Banco Central publishes a way of telling an old cycle fator de + * vencimento from a new cycle one, so every factor resolves to either of two dates 9000 days + * apart. `referenceDate` (now by default) picks between them through the library's own safety + * windows, which means the same slip can resolve to the other candidate as time passes: pass + * `referenceDate` explicitly whenever the answer has to stay stable. The search never goes below + * the first cycle, so a `referenceDate` older than the scheme itself still resolves a factor to + * the oldest date that factor can denote rather than to one before the 07/10/1997 base date. * * @param {string} value - The boleto digitable line (can be with or without mask). * @param {GetBoletoInfoOptions} [options] - Optional options. * @param {Date} options.referenceDate - Date used to resolve the "fator de vencimento" cycle. Defaults to now. - * @returns {BoletoInfo | undefined} An object containing amount (in cents), expirationDate, and bankCode, or undefined if the boleto is invalid. + * @returns {BoletoInfo | null} An object containing amount (in cents), expirationDate, and bankCode, or null if the boleto is invalid. * * @example * ```typescript - * getBoletoInfo('00190000090114971860168524522114675860000102656'); + * getBoletoInfo('00190000090114971860168524522114675860000102656', { + * referenceDate: new Date(2025, 5, 15), + * }); * // { amount: 102656, expirationDate: new Date(2018, 6, 15), bankCode: '001' } * * getBoletoInfo('846100000005246100291102005460339004695895061080'); * // { amount: 2461, expirationDate: null, bankCode: '', type: 'arrecadacao', segment: 4, value: 24.61, hasEffectiveValue: true } + * + * getBoletoInfo('invalid'); // null * ``` * - * Carta-Circular BCB nº 2.926/2000 specifies the linha digitável fields, the módulo 11 check - * digit (using 1 for remainders 0, 10 and 1) and the fator de vencimento behind the 47 digit - * cobrança bancária slip; the FEBRABAN layout index covers the arrecadação slip. See + * Carta-Circular BCB nº 2.926/2000 specifies the linha digitável fields and the módulo 11 + * check digit (using 1 for remainders 0, 10 and 1) of the 47 digit cobrança bancária slip, + * including the position of the fator de vencimento field. The FEBRABAN "Layout Padrão de + * Arrecadação/Recebimento com Utilização do Código de Barras" and the FEBRABAN layout index + * cover the arrecadação slip. The 22/02/2025 reset of the fator de vencimento is in neither: + * the Bradesco cobrança layout manual below reproduces the FEBRABAN rule. See * `src/get-boleto-info/constants.ts` for the fator de vencimento cycle base date and reset. * - * @see Official: https://cmsarquivos.febraban.org.br/Arquivos/documentos/PDF/Layout%20-%20C%C3%B3digo%20de%20Barras%20-%20Vers%C3%A3o%208%20-%2011_05_2026.pdf * @see Official: https://www.bcb.gov.br/pre/normativos/c_circ/2000/pdf/c_circ_2926_v1_O.pdf + * @see Official: https://cmsarquivos.febraban.org.br/Arquivos/documentos/PDF/Layout%20-%20C%C3%B3digo%20de%20Barras%20-%20Vers%C3%A3o%208%20-%2011_05_2026.pdf * @see Official: https://portal.febraban.org.br/pagina/3425/33/pt-br/layout-febraban + * @see Based on: https://banco.bradesco/assets/pessoajuridica/pdf/4008-524-0121-layout-cobranca-versao-portugues.pdf + * Bradesco "Layout da Cobrança" manual: base date 07/10/1997, 03/07/2000 = 1000, 21/02/2025 = 9999 + * and a restart at 1000 on 22/02/2025. */ -export const getBoletoInfo = ( - value: string, - options?: GetBoletoInfoOptions, -): BoletoInfo | undefined => { - if (!isValidBoleto(value)) return undefined; +export const getBoletoInfo = (value: string, options?: GetBoletoInfoOptions): BoletoInfo | null => { + if (!isValidBoleto(value)) return null; const sanitized = sanitizeToDigits(value); diff --git a/src/get-cbo/get-cbo.test.ts b/src/get-cbo/get-cbo.test.ts index d0c2b070..4d9c21c6 100644 --- a/src/get-cbo/get-cbo.test.ts +++ b/src/get-cbo/get-cbo.test.ts @@ -11,33 +11,46 @@ describe("getCbo", () => { it("should return the occupation for a code without a mask", () => { expect(getCbo("212405")).toEqual({ code: "212405", - title: "Analista de desenvolvimento de sistemas", + description: "Analista de desenvolvimento de sistemas", }); }); it("should return the occupation for a code with the hyphen mask", () => { expect(getCbo("2124-05")).toEqual({ code: "212405", - title: "Analista de desenvolvimento de sistemas", + description: "Analista de desenvolvimento de sistemas", }); }); it("should return the occupation for a code given as a number", () => { expect(getCbo(212_405)).toEqual({ code: "212405", - title: "Analista de desenvolvimento de sistemas", + description: "Analista de desenvolvimento de sistemas", }); }); - it("should pad a number to six digits so codes starting with zero resolve (0102-05, Oficial da aeronáutica)", () => { - expect(getCbo(10_205)).toEqual({ code: "010205", title: "Oficial da aeronáutica" }); - expect(getCbo("10205")).toBeNull(); + it("should pad to six digits so codes starting with zero resolve (0102-05, Oficial da aeronáutica)", () => { + const oficial = { code: "010205", description: "Oficial da aeronáutica" }; + + expect(getCbo(10_205)).toEqual(oficial); + expect(getCbo("10205")).toEqual(oficial); + expect(getCbo("010205")).toEqual(oficial); + }); + + it("should pad a string of bare digits exactly like the number it spells", () => { + expect(getCbo("10205")).toEqual(getCbo(10_205)); + expect(getCbo(" 10205 ")).toEqual(getCbo(10_205)); + }); + + it("should not pad a masked value, which already carries its separators", () => { + expect(getCbo("102-05")).toBeNull(); + expect(getCbo("0102-05")).toEqual({ code: "010205", description: "Oficial da aeronáutica" }); }); it("should resolve a code the official CSV carries and the community mirror did not (142135)", () => { expect(getCbo("142135")).toEqual({ code: "142135", - title: "Oficial de proteção de dados pessoais (dpo)", + description: "Oficial de proteção de dados pessoais (dpo)", }); }); @@ -49,11 +62,11 @@ describe("getCbo", () => { expect(getCbo("2124--05")).toBeNull(); expect(getCbo("2124-05")).toEqual({ code: "212405", - title: "Analista de desenvolvimento de sistemas", + description: "Analista de desenvolvimento de sistemas", }); expect(getCbo("2124 05")).toEqual({ code: "212405", - title: "Analista de desenvolvimento de sistemas", + description: "Analista de desenvolvimento de sistemas", }); }); @@ -67,7 +80,7 @@ describe("getCbo", () => { expect(getCbo("000000")).toBeNull(); }); - it("should return null when the digit count is not six", () => { + it("should return null for a padded short value no occupation carries and for a wider value", () => { expect(getCbo("21240")).toBeNull(); expect(getCbo("2124055")).toBeNull(); }); @@ -108,8 +121,12 @@ describe("getCbo", () => { test("should resolve every known code, as a string or a number, and agree with isValidCbo", () => { fc.assert( fc.property(codeArbitrary, (code) => { - expect(getCbo(code)).toEqual({ code, title: CBO_TITLES[code] }); - expect(getCbo(Number(code))).toEqual({ code, title: CBO_TITLES[code] }); + const expected = { code, description: CBO_TITLES[code] }; + const unpadded = String(Number(code)); + + expect(getCbo(code)).toEqual(expected); + expect(getCbo(Number(code))).toEqual(expected); + expect(getCbo(unpadded)).toEqual(expected); expect(isValidCbo(code)).toBe(true); }), ); @@ -120,7 +137,7 @@ describe("getCbo", () => { fc.property(codeArbitrary, (code) => { const masked = `${code.slice(0, 4)}-${code.slice(4)}`; - expect(getCbo(masked)).toEqual({ code, title: CBO_TITLES[code] }); + expect(getCbo(masked)).toEqual({ code, description: CBO_TITLES[code] }); }), ); }); @@ -131,6 +148,6 @@ describe("getCbo types", () => { test("should take a string or number and return a Cbo or null", () => { expectTypeOf(getCbo).parameter(0).toEqualTypeOf(); expectTypeOf(getCbo).returns.toEqualTypeOf(); - expectTypeOf().toEqualTypeOf<{ code: string; title: string }>(); + expectTypeOf().toEqualTypeOf<{ code: string; description: string }>(); }); }); diff --git a/src/get-cbo/get-cbo.ts b/src/get-cbo/get-cbo.ts index 9e7cfade..54025c04 100644 --- a/src/get-cbo/get-cbo.ts +++ b/src/get-cbo/get-cbo.ts @@ -1,5 +1,6 @@ import { CBO_FORMAT_REGEX, CBO_TITLES } from "../_internals/constants/cbo"; import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; +import { padLookupCode } from "../_internals/pad-lookup-code/pad-lookup-code"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; const CBO_LENGTH = 6; @@ -10,8 +11,8 @@ const CBO_LENGTH = 6; export type Cbo = { /** The 6 digit occupation code, without the hyphen mask. */ code: string; - /** The official occupation title. */ - title: string; + /** The official occupation description, the title the MTE table prints. */ + description: string; }; /** @@ -25,14 +26,20 @@ export type Cbo = { * since a sign, a decimal point or a rounded magnitude would otherwise be read as a code the * caller never wrote. * + * A CBO code is always 6 digits and its leading zeros are part of it, so a value written as + * bare digits is left padded with zeros to 6 whether it comes as a string or as a number: + * `10205`, `"10205"` and `"010205"` are the same code. A masked value already carries its + * separators and is read as written. + * * @param {string|number} value - The CBO code to look up, with or without the hyphen * mask, e.g. `"2124-05"`, `"212405"` or `212405`. * @returns {Cbo|null} The matching occupation, or null when the code is unknown or invalid. * * @example * ```typescript - * getCbo("2124-05"); // { code: "212405", title: "Analista de desenvolvimento de sistemas" } - * getCbo(10205); // { code: "010205", title: "Oficial da aeronáutica" } (a number is padded to 6 digits) + * getCbo("2124-05"); // { code: "212405", description: "Analista de desenvolvimento de sistemas" } + * getCbo(10205); // { code: "010205", description: "Oficial da aeronáutica" } (padded to 6 digits) + * getCbo("10205"); // { code: "010205", description: "Oficial da aeronáutica" } (padded to 6 digits) * getCbo("999999"); // null * getCbo("2124abc05"); // null (not a documented form) * getCbo(-212405); // null (not a non-negative safe integer) @@ -47,14 +54,14 @@ export type Cbo = { export const getCbo = (value: string | number): Cbo | null => { if (!isLookupCode(value)) return null; - const code = typeof value === "number" ? String(value).padStart(CBO_LENGTH, "0") : value.trim(); + const code = padLookupCode(value, CBO_LENGTH); if (!CBO_FORMAT_REGEX.test(code)) return null; const digits = sanitizeToDigits(code); - const title = CBO_TITLES[digits]; + const description = CBO_TITLES[digits]; - if (title === undefined) return null; + if (description === undefined) return null; - return { code: digits, title }; + return { code: digits, description }; }; diff --git a/src/get-cep-info-by-address/get-cep-info-by-address.test.ts b/src/get-cep-info-by-address/get-cep-info-by-address.test.ts index 5b3b5f6e..f4d485f0 100644 --- a/src/get-cep-info-by-address/get-cep-info-by-address.test.ts +++ b/src/get-cep-info-by-address/get-cep-info-by-address.test.ts @@ -86,6 +86,54 @@ describe("getCepInfoByAddress", () => { ).rejects.toThrow("Invalid UF: XX"); }); + it("should throw GetCepInfoByAddressValidationError when params is not an object, instead of a raw TypeError", async () => { + await Promise.all( + [undefined, null, "SP", 5, true].map((params) => + expect( + // @ts-expect-error: intentionally invalid input + getCepInfoByAddress(params), + ).rejects.toThrow(GetCepInfoByAddressValidationError), + ), + ); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("should include the message when params is not an object, reporting the argument rather than the UF", async () => { + await expect( + // @ts-expect-error: intentionally invalid input + getCepInfoByAddress(), + ).rejects.toThrow("UF, city and street are required"); + await expect( + // @ts-expect-error: intentionally invalid input + getCepInfoByAddress("SP"), + ).rejects.toThrow("UF, city and street are required"); + }); + + it("should throw GetCepInfoByAddressValidationError when federalUnit is missing or is not a string, instead of a raw TypeError", async () => { + await Promise.all( + [undefined, null, 35, {}, ["SP"]].map((federalUnit) => + expect( + // @ts-expect-error: intentionally invalid input + getCepInfoByAddress({ federalUnit, city: "São Paulo", street: "Avenida Paulista" }), + ).rejects.toThrow(GetCepInfoByAddressValidationError), + ), + ); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("should include the message when federalUnit is not a string", async () => { + await expect( + getCepInfoByAddress({ + // @ts-expect-error: intentionally invalid input + federalUnit: 35, + city: "São Paulo", + street: "Avenida Paulista", + }), + ).rejects.toThrow("Invalid UF: a two letter string is required"); + }); + it("should accept a federal unit with surrounding whitespace and lowercase letters", async () => { mockAddressListOnce([SAMPLE_ADDRESS]); diff --git a/src/get-cep-info-by-address/get-cep-info-by-address.ts b/src/get-cep-info-by-address/get-cep-info-by-address.ts index 0b1d2ea4..e41263de 100644 --- a/src/get-cep-info-by-address/get-cep-info-by-address.ts +++ b/src/get-cep-info-by-address/get-cep-info-by-address.ts @@ -1,5 +1,6 @@ import { DATA as STATES, type StateCode } from "../_internals/constants/states"; import { fetchWithRetry } from "../_internals/fetch-with-retry/fetch-with-retry"; +import { isNullish } from "../_internals/is-nullish/is-nullish"; import { removeAccents } from "../remove-accents/remove-accents"; /** Base class of every error `getCepInfoByAddress` rejects with. */ @@ -26,7 +27,11 @@ export class GetCepInfoByAddressNotFoundError extends GetCepInfoByAddressError { } } -/** One address returned by `getCepInfoByAddress`, under the field names ViaCEP itself uses. */ +/** + * One address returned by `getCepInfoByAddress`, under the field names ViaCEP itself uses. The + * ViaCEP payload is passed through unchanged, so every field the service sends is present and a + * field it adds later shows up even though it is not declared here. + */ export type CepAddressInfo = { /** The CEP, masked as "00000-000" the way ViaCEP returns it. */ cep: string; @@ -34,12 +39,18 @@ export type CepAddressInfo = { logradouro: string; /** Extra address information, e.g. a house number range. */ complemento: string; + /** Name of the establishment the CEP belongs to, e.g. "AC São Carlos"; empty for a street CEP. */ + unidade?: string; /** Neighborhood name. */ bairro: string; /** City name. */ localidade: string; /** Two letter state code, e.g. "SP". */ uf: string; + /** Full state name, e.g. "Minas Gerais". */ + estado?: string; + /** Region name, e.g. "Sudeste". */ + regiao?: string; /** The 7 digit IBGE municipality code. */ ibge?: string; /** GIA code, used by the São Paulo state tax authority. */ @@ -78,24 +89,51 @@ const isCepAddressInfoArray = (value: unknown): value is CepAddressInfo[] => Arr * @param {string} params.street - The street name, or part of it. * @returns {Promise} Every address matching the query. * @throws {GetCepInfoByAddressValidationError} When the UF, city or street is missing or invalid. + * A `params` that is not an object at all (omitted, `null`, a string) and a `federalUnit` that is + * not a string reject this way too, rather than with a raw `TypeError`. * @throws {GetCepInfoByAddressNotFoundError} When no address matches the query. * @throws {GetCepInfoByAddressError} When ViaCEP answers with an HTTP error status. A request * that cannot be performed at all rejects with the underlying `fetch` error instead. * * @example * ```typescript - * await getCepInfoByAddress({ federalUnit: "SP", city: "São Paulo", street: "Avenida Paulista" }); - * // [{ cep: "01310-100", logradouro: "Avenida Paulista", ... }] + * await getCepInfoByAddress({ federalUnit: "MG", city: "Ouro Preto", street: "Rua Direita" }); + * // [ + * // { + * // cep: "35411-152", + * // logradouro: "Rua Direita", + * // complemento: "", + * // unidade: "", + * // bairro: "Riacho (Amarantina)", + * // localidade: "Ouro Preto", + * // uf: "MG", + * // estado: "Minas Gerais", + * // regiao: "Sudeste", + * // ibge: "3146107", + * // gia: "", + * // ddd: "31", + * // siafi: "4921" + * // } + * // ] * ``` * * @see Official: https://www.correios.com.br/enviar/precisa-de-ajuda/tudo-sobre-cep - * @see Official: https://viacep.com.br/ + * @see Based on: https://viacep.com.br/ + * ViaCEP, the service queried. A third-party service, not a Correios one. */ -export const getCepInfoByAddress = async ({ - federalUnit, - city, - street, -}: GetCepInfoByAddressOptions): Promise => { +export const getCepInfoByAddress = async ( + params: GetCepInfoByAddressOptions, +): Promise => { + if (isNullish(params) || typeof params !== "object") { + throw new GetCepInfoByAddressValidationError("UF, city and street are required"); + } + + const { federalUnit, city, street } = params; + + if (typeof federalUnit !== "string") { + throw new GetCepInfoByAddressValidationError("Invalid UF: a two letter string is required"); + } + const normalizedUf = federalUnit.trim().toUpperCase(); if (!isStateCode(normalizedUf)) { diff --git a/src/get-certidao-info/constants.ts b/src/get-certidao-info/constants.ts new file mode 100644 index 00000000..6ebaf08b --- /dev/null +++ b/src/get-certidao-info/constants.ts @@ -0,0 +1,47 @@ +/** + * The nine books (tipo do livro) a matrícula de registro civil can point to, in the order of + * the codes 1 to 9: Livro A (nascimento), Livro B (casamento), Livro B Auxiliar (casamento + * religioso com efeito civil), Livro C (óbito), Livro C Auxiliar (natimorto), Livro D + * (proclamas), Livro E (demais atos), Livro E desdobrado para emancipações and Livro E + * desdobrado para interdições. + * + * The in-force art. 473, V of the Código Nacional de Normas da Corregedoria Nacional de Justiça + * lists only the codes 1 to 7, and no CNJ primary text reachable today publishes the other two: + * the Anexo IV of the revoked Provimento CNJ nº 63/2017 lists the same seven. The codes 8 + * (emancipação) and 9 (interdição) come from the `Based on:` references below: ghiorzi.org prints + * the nine book list and the cited Casilhero support class maps the same nine. They are kept + * because matrículas carrying them circulate. + * + * @see Official: https://atos.cnj.jus.br/atos/detalhar/5243 + * Código Nacional de Normas da Corregedoria Nacional de Justiça - Foro Extrajudicial (Provimento + * CNJ nº 149/2023), art. 473 as currently published: the in-force layout of the 32 digit + * matrícula. Inciso II and §§ 1º and 3º to 5º carry the redação of the Provimento CN nº 237, de + * 13/07/2026; the rest of the article, § 2º included, and the digit layout this library depends + * on, come from the Provimento CN nº 182, de 17/09/2024. + * @see Official: https://atos.cnj.jus.br/atos/detalhar/1311 + * Provimento CNJ nº 2, de 27/04/2009, art. 1º and 2º, which instituted the modelos únicos de + * certidão and ordered that "as certidões passarão a consignar matrícula que identifica o código + * nacional da serventia, o código do acervo, o tipo do serviço prestado, o tipo do livro, o número + * do livro, o número da folha, o número do termo e o digito verificador" (revoked; historical). + * @see Official: https://atos.cnj.jus.br/atos/detalhar/1310 + * Provimento CNJ nº 3, de 17/11/2009, art. 7º, which is where that matrícula first got its digit + * structure: "a matrícula, de inserção obrigatória nas certidões (primeira e demais vias) emitidas + * pelos Cartórios de Registro Civil das Pessoas Naturais a partir de 1º de janeiro de 2010, é + * formada pelos seguintes elementos", incisos I to IX fixing the same 6 + 2 + 2 + 4 + 1 + 5 + 3 + + * 7 + 2 positions art. 473 carries today (revoked; historical). + * @see Based on: http://ghiorzi.org/DVnew.htm + * Description of the nine books and their codes. + * @see Based on: https://github.com/Casilhero/brazilian-validators/blob/main/src/Support/CertidaoInfo.php + * Reference implementation agreeing on the same nine books, in the same order. + */ +export const CERTIDAO_TYPES = [ + "birth", + "marriage", + "religious-marriage", + "death", + "stillbirth", + "banns", + "other", + "emancipation", + "interdiction", +] as const; diff --git a/src/get-certidao-info/get-certidao-info.test.ts b/src/get-certidao-info/get-certidao-info.test.ts new file mode 100644 index 00000000..136e0bcf --- /dev/null +++ b/src/get-certidao-info/get-certidao-info.test.ts @@ -0,0 +1,208 @@ +import * as fc from "fast-check"; + +import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; +import { CERTIDAO_TYPES } from "./constants"; +import { getCertidaoInfo, type CertidaoInfo, type CertidaoType } from "./get-certidao-info"; + +const findMatricula = (base: string): string => { + for (let pair = 0; pair < 100; pair++) { + const value = `${base}${String(pair).padStart(2, "0")}`; + + if (getCertidaoInfo(value) !== null) return value; + } + + return ""; +}; + +describe("getCertidaoInfo", () => { + describe("should return null", () => { + test("when it is null", () => { + // @ts-expect-error: intentionally invalid input + expect(getCertidaoInfo(null)).toBeNull(); + }); + + test("when it is undefined", () => { + // @ts-expect-error: intentionally invalid input + expect(getCertidaoInfo()).toBeNull(); + }); + + test("when it is an empty string", () => { + expect(getCertidaoInfo("")).toBeNull(); + }); + + test("when the check digits do not match", () => { + expect(getCertidaoInfo("10453901552013100012021000012322")).toBeNull(); + }); + + test("when the matrícula is otherwise invalid", () => { + expect(getCertidaoInfo("not-a-matricula")).toBeNull(); + }); + + test("when the book code is 0, outside the nine books of the Provimento", () => { + expect(getCertidaoInfo("10453901552013000012021000012387")).toBeNull(); + }); + + test("when the serviço is not the 55 of art. 473, III, even with matching check digits", () => { + expect(getCertidaoInfo("09400301542011100110002005191744")).toBeNull(); + }); + + test("when it is a number, which cannot carry the 32 significant digits of a matrícula", () => { + // @ts-expect-error: intentionally invalid input + expect(getCertidaoInfo(1_045_390_155)).toBeNull(); + }); + }); + + describe("should return the parsed matrícula", () => { + test("for 104539.01.55.2013.1.00012.021.0000123-21, the worked example of ghiorzi.org/DVnew.htm", () => { + expect(getCertidaoInfo("104539 01 55 2013 1 00012 021 0000123 21")).toEqual({ + registryCns: "104539", + acervo: "01", + service: "55", + year: 2013, + type: "birth", + typeCode: 1, + book: "00012", + page: "021", + term: "0000123", + checkDigits: "21", + }); + }); + + test("for 094300 01 55 2010 1 00020 112 0000120-87 (klawdyo/validation-br certidao.spec.ts)", () => { + expect(getCertidaoInfo("094300 01 55 2010 1 00020 112 0000120-87")).toEqual({ + registryCns: "094300", + acervo: "01", + service: "55", + year: 2010, + type: "birth", + typeCode: 1, + book: "00020", + page: "112", + term: "0000120", + checkDigits: "87", + }); + }); + + test("for a marriage act, book code 2", () => { + expect(getCertidaoInfo("10453901552013200012021000012376")?.type).toBe("marriage"); + }); + + test("for a religious marriage with civil effect, book code 3", () => { + expect(getCertidaoInfo("10453901552013300012021000012310")?.type).toBe("religious-marriage"); + }); + + test("for a death act, book code 4", () => { + expect(getCertidaoInfo("10453901552013400012021000012365")?.type).toBe("death"); + }); + + test("for a stillbirth act, book code 5", () => { + expect(getCertidaoInfo("10453901552013500012021000012301")?.type).toBe("stillbirth"); + }); + + test("for a proclamas act, book code 6", () => { + expect(getCertidaoInfo("10453901552013600012021000012354")?.type).toBe("banns"); + }); + + test("for the other acts of Livro E, book code 7", () => { + expect(getCertidaoInfo("10453901552013700012021000012315")?.type).toBe("other"); + }); + + test("for an emancipation act, book code 8", () => { + expect(getCertidaoInfo("10453901552013800012021000012343")?.type).toBe("emancipation"); + }); + + test("for an interdiction act, book code 9", () => { + expect(getCertidaoInfo("10453901552013900012021000012398")?.type).toBe("interdiction"); + }); + + test("for a matrícula whose first modulus 11 remainder is 10 (826683 01 55 2015 2 09245 842 9990114 18)", () => { + expect(getCertidaoInfo("82668301552015209245842999011418")).toEqual({ + registryCns: "826683", + acervo: "01", + service: "55", + year: 2015, + type: "marriage", + typeCode: 2, + book: "09245", + page: "842", + term: "9990114", + checkDigits: "18", + }); + }); + }); + + describe("properties", () => { + const parts = fc.tuple( + fc.stringMatching(/^[0-9]{6}$/), + fc.stringMatching(/^[0-9]{2}$/), + fc.constant("55"), + fc.integer({ min: 1000, max: 9999 }), + fc.integer({ min: 1, max: 9 }), + fc.stringMatching(/^[0-9]{5}$/), + fc.stringMatching(/^[0-9]{3}$/), + fc.stringMatching(/^[0-9]{7}$/), + ); + + test("should give back every field of a valid matrícula", () => { + fc.assert( + fc.property(parts, (fields) => { + const [registryCns, acervo, service, year, typeCode, book, page, term] = fields; + const registry = `${registryCns}${acervo}${service}${year}${typeCode}`; + const value = findMatricula(`${registry}${book}${page}${term}`); + const parsed = getCertidaoInfo(value); + + expect(parsed?.registryCns).toBe(registryCns); + expect(parsed?.acervo).toBe(acervo); + expect(parsed?.service).toBe(service); + expect(parsed?.year).toBe(year); + expect(parsed?.typeCode).toBe(typeCode); + expect(parsed?.book).toBe(book); + expect(parsed?.page).toBe(page); + expect(parsed?.term).toBe(term); + expect(parsed?.checkDigits).toBe(value.slice(30)); + expect(parsed?.type).toBe(CERTIDAO_TYPES[typeCode - 1]); + }), + ); + }); + + test("should never throw and always return a matrícula or null", () => { + fc.assert( + fc.property(fc.anything(), (value) => { + const parsed = getCertidaoInfo(value as string); + + expect(parsed === null || typeof parsed.registryCns === "string").toBe(true); + }), + ); + }); + }); +}); + +describe("getCertidaoInfo types", () => { + test("should take a string and return a CertidaoInfo or null", () => { + expectTypeOf(getCertidaoInfo).parameter(0).toEqualTypeOf(); + expectTypeOf(getCertidaoInfo).returns.toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<{ + registryCns: string; + acervo: string; + service: string; + year: number; + type: CertidaoType; + typeCode: number; + book: string; + page: string; + term: string; + checkDigits: string; + }>(); + expectTypeOf().toEqualTypeOf< + | "birth" + | "marriage" + | "religious-marriage" + | "death" + | "stillbirth" + | "banns" + | "other" + | "emancipation" + | "interdiction" + >(); + }); +}); diff --git a/src/get-certidao-info/get-certidao-info.ts b/src/get-certidao-info/get-certidao-info.ts new file mode 100644 index 00000000..6bb9f134 --- /dev/null +++ b/src/get-certidao-info/get-certidao-info.ts @@ -0,0 +1,128 @@ +import { CERTIDAO_BASE_LENGTH } from "../_internals/constants/certidao"; +import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { isValidCertidao } from "../is-valid-certidao/is-valid-certidao"; +import { CERTIDAO_TYPES } from "./constants"; + +/** + * The nine books (tipo do livro) a matrícula de registro civil can point to, in the order of the + * codes 1 to 9. `getCertidaoInfo` names the book of a matrícula with one of these, and + * `isValidCertidao` accepts a list of them. + * + * The in-force art. 473, V of the Código Nacional de Normas da Corregedoria Nacional de Justiça + * lists only the codes 1 to 7, and no CNJ primary text reachable today publishes the other two: + * the Anexo IV of the revoked Provimento CNJ nº 63/2017 lists the same seven. The codes 8 + * (`"emancipation"`) and 9 (`"interdiction"`) come from the `Based on:` references below: ghiorzi.org and + * validation-br both print the nine book list. They are kept because matrículas carrying them + * circulate. + */ +export type CertidaoType = + | "birth" + | "marriage" + | "religious-marriage" + | "death" + | "stillbirth" + | "banns" + | "other" + | "emancipation" + | "interdiction"; + +/** The fields `getCertidaoInfo` reads out of the matrícula of a certidão de registro civil. */ +export type CertidaoInfo = { + /** The 6 digit CNS (Código Nacional de Serventia) of the serventia that issued the act. */ + registryCns: string; + /** + * Acervo the book belongs to: `"01"` the serventia's own acervo; `"02"` and up, one per + * incorporated acervo. Art. 473, §§ 3º to 5º splits the incorporated ones by the date the + * origin serventia was extinguished or deactivated: up to 31 December 2009 the matrícula + * carries the CNS of the incorporating unit and an acervo code from `"02"` up, one per + * incorporation in their numeric order; from 1 January 2010 on it carries the CNS of the + * incorporated unit itself and the acervo code `"01"`, counted as that unit's own acervo. When + * one acervo is split between two or more successor serventias, each of them uses its own CNS + * with the acervo code `"02"`. + */ + acervo: string; + /** Service rendered by the serventia, always "55", the registro civil das pessoas naturais. */ + service: string; + /** Four digit year the act was recorded. */ + year: number; + /** The book the act belongs to, as an English name. */ + type: CertidaoType; + /** Raw book code, 1 to 9, as printed in the fifteenth position of the matrícula. */ + typeCode: number; + /** The 5 digit book (livro) number, zero padded. */ + book: string; + /** The 3 digit page (folha) number, zero padded. */ + page: string; + /** The 7 digit term (termo) number, zero padded. */ + term: string; + /** The 2 modulus 11 check digits of the matrícula. */ + checkDigits: string; +}; + +/** + * Parses the matrícula of a certidão de registro civil into its fields. + * + * Accepts the same input forms as `isValidCertidao` and returns `null` when the matrícula is + * not valid, which includes a serviço other than the `55` art. 473, III fixes for the registro + * civil das pessoas naturais, and a book code that is not one of the nine books defined by the + * Provimento, since an unknown book cannot be named. + * + * Only a string is accepted: the 32 digits of a matrícula are more than a JavaScript number can + * hold, so a numeric argument always gives `null` instead of being read as a rounded value. + * + * @param {string} value - The matrícula value to be parsed. + * @returns {CertidaoInfo | null} The parsed matrícula, or `null` when it is not valid. + * + * @example + * ```typescript + * getCertidaoInfo("104539 01 55 2013 1 00012 021 0000123 21"); + * // { registryCns: "104539", acervo: "01", service: "55", year: 2013, type: "birth", + * // typeCode: 1, book: "00012", page: "021", term: "0000123", checkDigits: "21" } + * + * getCertidaoInfo("invalid"); // null + * ``` + * + * @see Official: https://atos.cnj.jus.br/atos/detalhar/5243 + * Código Nacional de Normas da Corregedoria Nacional de Justiça - Foro Extrajudicial (Provimento + * CNJ nº 149/2023), art. 473 as currently published: the in-force layout of the 32 digit + * matrícula. Inciso II and §§ 1º and 3º to 5º carry the redação of the Provimento CN nº 237, de + * 13/07/2026; the rest of the article, § 2º included, and the digit layout this library depends + * on, come from the Provimento CN nº 182, de 17/09/2024. + * @see Official: https://atos.cnj.jus.br/atos/detalhar/1311 + * Provimento CNJ nº 2, de 27/04/2009, art. 1º and 2º, which instituted the modelos únicos de + * certidão and ordered that "as certidões passarão a consignar matrícula que identifica o código + * nacional da serventia, o código do acervo, o tipo do serviço prestado, o tipo do livro, o número + * do livro, o número da folha, o número do termo e o digito verificador" (revoked; historical). + * @see Official: https://atos.cnj.jus.br/atos/detalhar/1310 + * Provimento CNJ nº 3, de 17/11/2009, art. 7º, which is where that matrícula first got its digit + * structure: "a matrícula, de inserção obrigatória nas certidões (primeira e demais vias) emitidas + * pelos Cartórios de Registro Civil das Pessoas Naturais a partir de 1º de janeiro de 2010, é + * formada pelos seguintes elementos", incisos I to IX fixing the same 6 + 2 + 2 + 4 + 1 + 5 + 3 + + * 7 + 2 positions art. 473 carries today (revoked; historical). + * @see Based on: http://ghiorzi.org/DVnew.htm + * Worked example of the two check digits (sums 288 and 309). + * @see Based on: https://github.com/klawdyo/validation-br/blob/feat-certidao/src/certidao.ts + * Reference implementation, and the source of the matrículas used as test vectors. + * @see Based on: https://github.com/geekcom/validator-docs/blob/master/src/validator-docs/Rules/Certidao.php + * Third reference implementation agreeing on the weights and on the remainder of 10 read as 1. + */ +export const getCertidaoInfo = (value: string): CertidaoInfo | null => { + if (!isValidCertidao(value)) return null; + + const digits = sanitizeToDigits(value); + const typeCode = digits.charCodeAt(14) - 48; + const type = CERTIDAO_TYPES[typeCode - 1]; + + return { + registryCns: digits.slice(0, 6), + acervo: digits.slice(6, 8), + service: digits.slice(8, 10), + year: Number(digits.slice(10, 14)), + type, + typeCode, + book: digits.slice(15, 20), + page: digits.slice(20, 23), + term: digits.slice(23, CERTIDAO_BASE_LENGTH), + checkDigits: digits.slice(CERTIDAO_BASE_LENGTH), + }; +}; diff --git a/src/get-cfop/get-cfop.ts b/src/get-cfop/get-cfop.ts index b7ddca6c..608a4d5f 100644 --- a/src/get-cfop/get-cfop.ts +++ b/src/get-cfop/get-cfop.ts @@ -29,6 +29,10 @@ export type Cfop = { * safe integer, since a sign, a decimal point or a rounded magnitude would otherwise be read * as a code the caller never wrote. * + * No CFOP code starts with a zero, its first digit is the operation group (1 to 7), so nothing + * is ever padded here: a number and the string of the same digits are read identically, and a + * value narrower than 4 digits is not a code at all. + * * @param {string|number} value - The CFOP code to look up, with or without the `N.NNN` mask, * e.g. `"1.101"`, `"1101"` or `1101`. * @returns {Cfop|null} The matching CFOP entry, or null when the code is unknown or @@ -48,6 +52,8 @@ export type Cfop = { * Anexo II of Convênio SINIEF s/nº 1970, the CFOP table in force. * @see Official: https://www.confaz.fazenda.gov.br/legislacao/ajustes/sinief/cvsn_70 * Convênio SINIEF s/nº 1970, the consolidated text the annex belongs to. + * @see Official: https://www.confaz.fazenda.gov.br/legislacao/ajustes/2025/AJ039_25 + * Ajuste SINIEF 39/25, the last amendment the annex carries (CFOP 7.667, from 01.02.26). * @see Official: https://www.confaz.fazenda.gov.br/legislacao/ajustes/2001/AJ_007_01 * Ajuste SINIEF 07/01, the historical text that gave the CFOP its 4 digit form. */ diff --git a/src/get-cities/get-cities.test.ts b/src/get-cities/get-cities.test.ts index be93e5f3..1b3aa49d 100644 --- a/src/get-cities/get-cities.test.ts +++ b/src/get-cities/get-cities.test.ts @@ -38,11 +38,28 @@ describe("getCities", () => { expect(cities).toEqual(sorted); }); + it("should sort every per-state list with the pt-BR comparator", () => { + for (const state of getStates()) { + const cities = getCities(state.code); + const sorted = [...cities].sort((a, b) => a.localeCompare(b, "pt-BR")); + + expect(cities).toEqual(sorted); + } + }); + it("should return empty array if state does not exist", () => { // @ts-expect-error: intentionally invalid input expect(getCities("ACC")).toEqual([]); }); + it("should return empty array for a truthy state that is not a string instead of throwing", () => { + expect(getCities(Object.create(null))).toEqual([]); + // @ts-expect-error: intentionally invalid input + expect(getCities(35)).toEqual([]); + // @ts-expect-error: intentionally invalid input + expect(getCities(["SP"])).toEqual([]); + }); + it("should return empty array for inherited Object property names instead of throwing", () => { // @ts-expect-error: intentionally invalid input expect(getCities("toString")).toEqual([]); diff --git a/src/get-cities/get-cities.ts b/src/get-cities/get-cities.ts index 81402207..eb71c02a 100644 --- a/src/get-cities/get-cities.ts +++ b/src/get-cities/get-cities.ts @@ -1,16 +1,28 @@ import { DATA as CITIES_DATA } from "../_internals/constants/cities"; import { type StateCode } from "../_internals/constants/states"; +export type { StateCode } from "../_internals/constants/states"; + let allCitiesCache: string[] | undefined; /** * Returns a list of city names for a given Brazilian state, or all cities if no state is specified. * - * If a state code is provided, the function returns its cities sorted alphabetically. - * If no state is provided, it returns all cities from all states, sorted with - * `localeCompare` in the "pt-BR" locale so accented names land where a Brazilian reader - * expects them (the combined, sorted list is computed once and cached; every call returns - * a fresh copy). + * If a state code is provided, the function returns its cities sorted with `localeCompare` + * in the "pt-BR" locale. If no state is provided, it returns all cities from all states, + * sorted the same way so accented names land where a Brazilian reader expects them (the + * combined, sorted list is computed once and cached; every call returns a fresh copy). + * + * Every falsy `state` asks for the full list, so `getCities(null)` and `getCities("")` return + * every city. The sibling `getMunicipalities` is stricter and only reads an omitted (or + * `undefined`) state code that way, returning `[]` for `null` and `""`. + * + * The state code is matched exactly, case included: `getCities("sp")` returns `[]` where + * `getCities("SP")` returns the 645 São Paulo cities. `getCities` and `getMunicipalities` are + * the only state-taking lookups that are case-sensitive; `getStateNameByCode`, + * `getTimezoneByState`, `getAreaCodesByState` and `getMunicipality` all fold case. + * + * @deprecated Use `getMunicipalities` instead. * * @param {StateCode} [state] - The code of the Brazilian state to filter cities by. Optional. * @returns {string[]} An array of city names, sorted alphabetically. Returns an empty array if the state is not found. @@ -18,6 +30,7 @@ let allCitiesCache: string[] | undefined; * @example * ```typescript * getCities("SP")[0]; // "Adamantina" + * getCities("sp"); // [] (the state code is case-sensitive here) * getCities().length; // every city of every state * ``` * @@ -33,7 +46,7 @@ export const getCities = (state?: StateCode): string[] => { return [...allCitiesCache]; } - if (!Object.hasOwn(CITIES_DATA, state)) return []; + if (typeof state !== "string" || !Object.hasOwn(CITIES_DATA, state)) return []; return CITIES_DATA[state].map(([name]) => name); }; diff --git a/src/get-cnae/get-cnae.test.ts b/src/get-cnae/get-cnae.test.ts index cf8c4af6..e286c7f3 100644 --- a/src/get-cnae/get-cnae.test.ts +++ b/src/get-cnae/get-cnae.test.ts @@ -11,28 +11,46 @@ import { getCnae, type Cnae } from "./get-cnae"; describe("getCnae", () => { it("should return the CNAE entry for a known code as a string", () => { expect(getCnae("6201501")).toEqual({ - code: "6201-5/01", + code: "6201501", description: "DESENVOLVIMENTO DE PROGRAMAS DE COMPUTADOR SOB ENCOMENDA", }); }); it("should return the CNAE entry for a known code as a number", () => { expect(getCnae(6_201_501)).toEqual({ - code: "6201-5/01", + code: "6201501", description: "DESENVOLVIMENTO DE PROGRAMAS DE COMPUTADOR SOB ENCOMENDA", }); }); it("should return the CNAE entry for a masked code", () => { expect(getCnae("6201-5/01")).toEqual({ - code: "6201-5/01", + code: "6201501", description: "DESENVOLVIMENTO DE PROGRAMAS DE COMPUTADOR SOB ENCOMENDA", }); }); - it("should pad a number to seven digits so codes starting with zero resolve (0111-3/01, cultivo de arroz)", () => { - expect(getCnae(111_301)).toEqual({ code: "0111-3/01", description: "CULTIVO DE ARROZ" }); - expect(getCnae("111301")).toBeNull(); + it("should pad to seven digits so codes starting with zero resolve (0111-3/01, cultivo de arroz)", () => { + const arroz = { code: "0111301", description: "CULTIVO DE ARROZ" }; + + expect(getCnae(111_301)).toEqual(arroz); + expect(getCnae("111301")).toEqual(arroz); + expect(getCnae("0111301")).toEqual(arroz); + }); + + it("should pad a string of bare digits exactly like the number it spells", () => { + expect(getCnae("111301")).toEqual(getCnae(111_301)); + expect(getCnae(" 111301 ")).toEqual(getCnae(111_301)); + }); + + it("should not pad a masked value, which already carries its separators", () => { + expect(getCnae("111-3/01")).toBeNull(); + expect(getCnae("0111-3/01")).toEqual({ code: "0111301", description: "CULTIVO DE ARROZ" }); + }); + + it("should return the bare digits as the code and leave the mask to formatCnae", () => { + expect(getCnae("6201-5/01")?.code).toBe("6201501"); + expect(formatCnae(getCnae("6201-5/01")?.code ?? "")).toBe("6201-5/01"); }); it("should return a fresh object on every call", () => { @@ -45,7 +63,7 @@ describe("getCnae", () => { expect(getCnae("0000000")).toBeNull(); }); - it("should return null for a code with a digit count different from seven", () => { + it("should return null for a padded short value no subclass carries", () => { expect(getCnae("620150")).toBeNull(); }); @@ -81,10 +99,13 @@ describe("getCnae", () => { test("should resolve every known code, as a string or a number, and agree with formatCnae and isValidCnae", () => { fc.assert( fc.property(codeArbitrary, (code) => { - const expected = { code: formatCnae(code), description: CNAE_SUBCLASSES[code] }; + const expected = { code, description: CNAE_SUBCLASSES[code] }; + const unpadded = String(Number(code)); expect(getCnae(code)).toEqual(expected); expect(getCnae(Number(code))).toEqual(expected); + expect(getCnae(unpadded)).toEqual(expected); + expect(formatCnae(getCnae(code)?.code ?? "")).toBe(formatCnae(code)); expect(isValidCnae(code)).toBe(true); }), ); diff --git a/src/get-cnae/get-cnae.ts b/src/get-cnae/get-cnae.ts index ac822352..aa28ad74 100644 --- a/src/get-cnae/get-cnae.ts +++ b/src/get-cnae/get-cnae.ts @@ -1,7 +1,7 @@ import { CNAE_FORMAT_REGEX, CNAE_SUBCLASSES } from "../_internals/constants/cnae"; import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; +import { padLookupCode } from "../_internals/pad-lookup-code/pad-lookup-code"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; -import { formatCnae } from "../format-cnae/format-cnae"; const CNAE_LENGTH = 7; @@ -9,7 +9,7 @@ const CNAE_LENGTH = 7; * A CNAE (Classificação Nacional de Atividades Econômicas) subclass. */ export type Cnae = { - /** The subclass code formatted as `NNNN-N/NN`. */ + /** The 7 digit subclass code, without the mask. Use `formatCnae` for the `NNNN-N/NN` form. */ code: string; /** The official subclass description. */ description: string; @@ -17,7 +17,7 @@ export type Cnae = { /** * Looks a CNAE (Classificação Nacional de Atividades Econômicas) subclass code up in the - * official CNAE 2.3 table. + * official CNAE-Subclasses 2.3 table, the current subclass revision of CNAE 2.0. * * A string is only read as a code when it is written in one of the documented forms: the 7 * digits, or the `NNNN-N/NN` mask, with a single separator (space, `.`, `-` or `/`) between the groups and optional @@ -26,26 +26,37 @@ export type Cnae = { * since a sign, a decimal point or a rounded magnitude would otherwise be read as a code the * caller never wrote. * + * A CNAE subclass code is always 7 digits and its leading zeros are part of it, so a value + * written as bare digits is left padded with zeros to 7 whether it comes as a string or as a + * number: `111301`, `"111301"` and `"0111301"` are the same code. A masked value already + * carries its separators and is read as written. + * + * `code` comes back as those 7 bare digits, like every other lookup of this library; pass it to + * `formatCnae` for the `NNNN-N/NN` form. + * * @param {string|number} value - The CNAE code to look up, with or without the * `NNNN-N/NN` mask. * @returns {Cnae|null} The matching subclass, or null when the code is unknown or invalid. * * @example * ```typescript - * getCnae("6201501"); // { code: "6201-5/01", description: "DESENVOLVIMENTO DE PROGRAMAS DE COMPUTADOR SOB ENCOMENDA" } - * getCnae(111301); // { code: "0111-3/01", description: "CULTIVO DE ARROZ" } (a number is padded to 7 digits) + * getCnae("6201-5/01"); // { code: "6201501", description: "DESENVOLVIMENTO DE PROGRAMAS DE COMPUTADOR SOB ENCOMENDA" } + * getCnae(111301); // { code: "0111301", description: "CULTIVO DE ARROZ" } (padded to 7 digits) + * getCnae("111301"); // { code: "0111301", description: "CULTIVO DE ARROZ" } (padded to 7 digits) * getCnae("0000000"); // null * getCnae("0111abc301"); // null (not a documented form) + * formatCnae(getCnae("6201501")?.code); // "6201-5/01" (the mask is the formatter's job) * getCnae(-111301); // null (not a non-negative safe integer) * ``` * * @see Official: https://servicodados.ibge.gov.br/api/v2/cnae/subclasses + * @see Official: https://concla.ibge.gov.br/busca-online-cnae.html + * CONCLA's CNAE search and structure browser, which publishes CNAE-Subclasses 2.3. */ export const getCnae = (value: string | number): Cnae | null => { if (!isLookupCode(value)) return null; - const subclass = - typeof value === "number" ? String(value).padStart(CNAE_LENGTH, "0") : value.trim(); + const subclass = padLookupCode(value, CNAE_LENGTH); if (!CNAE_FORMAT_REGEX.test(subclass)) return null; @@ -54,5 +65,5 @@ export const getCnae = (value: string | number): Cnae | null => { if (description === undefined) return null; - return { code: formatCnae(digits), description }; + return { code: digits, description }; }; diff --git a/src/get-format-license-plate/get-format-license-plate.test.ts b/src/get-format-license-plate/get-format-license-plate.test.ts index cf3f122b..da42ca91 100644 --- a/src/get-format-license-plate/get-format-license-plate.test.ts +++ b/src/get-format-license-plate/get-format-license-plate.test.ts @@ -46,6 +46,13 @@ describe("getFormatLicensePlate", () => { }); }); +describe("getFormatLicensePlate with an array of characters", () => { + test("should reject it instead of reading it as the joined string", () => { + // @ts-expect-error: intentionally invalid input + expect(getFormatLicensePlate(["A", "B", "C", "1", "D", "2", "3"])).toBeNull(); + }); +}); + describe("getFormatLicensePlate types", () => { test("should take a string and return a license plate format or null", () => { expectTypeOf(getFormatLicensePlate).parameter(0).toEqualTypeOf(); diff --git a/src/get-format-license-plate/get-format-license-plate.ts b/src/get-format-license-plate/get-format-license-plate.ts index 9c76c3e9..e474e2c7 100644 --- a/src/get-format-license-plate/get-format-license-plate.ts +++ b/src/get-format-license-plate/get-format-license-plate.ts @@ -29,11 +29,18 @@ export type LicensePlateFormat = "LLLNNNN" | "LLLNLNN"; * getFormatLicensePlate("ABC1234EXTRA"); // null (too many characters) * ``` * + * The resolution's own text does not spell the sequence out: art. 2º § 2º delegates the + * technical specification to Anexo I, whose item 1.2 reads "O padrão de estampagem é composto de + * 7 (sete) caracteres alfanuméricos, em alto relevo, na sequência LLLNLNN" and whose item 1.2.1 + * reads `L` as a letter and `N` as a numeral. Art. 2º § 1º puts a single rear plate of that same + * standard on motorcycles and similar vehicles, and art. 2º § 3º describes the old `AAA-1111` + * PNU it coexists with. The annexes are published in a PDF of their own, cited below alongside + * the resolution's text. + * * @see Official: https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes/resolucao9692022.pdf + * @see Official: https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes/resolucao9692022anexos.pdf */ export const getFormatLicensePlate = (value: string): LicensePlateFormat | null => { - if (typeof value !== "string") return null; - if (sanitizeToAlphanumeric(value).length !== LENGTH) return null; const parsed = parseLicensePlate(value); diff --git a/src/get-holidays/constants.ts b/src/get-holidays/constants.ts index 345daeb2..b2e26c6f 100644 --- a/src/get-holidays/constants.ts +++ b/src/get-holidays/constants.ts @@ -6,6 +6,7 @@ export type StateHolidayEntry = { day?: number; month?: number; easterOffset?: number; + nextSundayWhenWeekday?: boolean; type?: HolidayType; since?: number; until?: number; @@ -24,55 +25,229 @@ export const FIXED_HOLIDAYS = { export const CONSCIENCIA_NEGRA_NATIONAL_SINCE_YEAR = 2024; +/** + * Lei 14.759/2023 names the holiday "Dia Nacional de Zumbi e da Consciência Negra"; the shorter + * form below is the one 2.3.0 emitted and is kept so the output does not change. + */ export const CONSCIENCIA_NEGRA_HOLIDAY_NAME = "Dia da Consciência Negra"; export const LEGACY_CONSCIENCIA_NEGRA_HOLIDAY_NAME = "Consciência Negra"; +/** First year Alagoas' 16 September is a feriado estadual, not a ponto facultativo (Lei AL nº 9.358/2024). */ +export const AL_EMANCIPACAO_FERIADO_SINCE_YEAR = 2024; + +/** First year Paraíba's 26 July is no longer a holiday: Lei PB nº 10.601/2015 revoked its basis on 17/12/2015. */ +export const PB_MORTE_JOAO_PESSOA_UNTIL_YEAR = 2016; + +/** First year Tocantins' 18 March is no longer a holiday: Lei TO nº 2.013/2009 repealed the feriado clause on 18/02/2009. */ +export const TO_AUTONOMIA_UNTIL_YEAR = 2009; + +/** + * First year Santa Catarina's 25 November moves to the following Sunday: Lei SC nº 11.213, de + * 11/11/1999, added the transfer clause to Lei SC nº 10.306/1996 and, by its art. 2º, entered + * into force on the day it was published (DO 16.290, de 12/11/1999), thirteen days before that + * year's 25 November. + */ +export const SC_ALEXANDRIA_TRANSFER_SINCE_YEAR = 1999; + +/** + * The one year Santa Catarina's 25 November is observed on the statutory date again: art. 3º of + * Lei SC nº 12.906, de 22/01/2004, revoked Lei SC nº 11.213/1999 outright and its own art. 1º did + * not carry the transfer clause forward, leaving 2004 without one until Lei SC nº 13.408/2005 + * reinstated it. + */ +export const SC_ALEXANDRIA_TRANSFER_GAP_YEAR = 2004; + /** - * Feriados estaduais por lei estadual, um por UF (uma UF pode ter mais de um `@see`). + * First year Santa Catarina's 11 August and 25 November both move to the following Sunday: Lei SC + * nº 13.408, de 15/07/2005, added the transfer clause covering the two dates and entered into + * force on the day it was published (DO 17.680, de 15/07/2005), before that year's 11 August. Up + * to 2004 the 11 August holiday was always observed on the date itself. + */ +export const SC_NEXT_SUNDAY_TRANSFER_SINCE_YEAR = 2005; + +/** + * Feriados estaduais, um `@see` por entrada. * - * @see Based on: https://pt.wikipedia.org/wiki/Acre Lei AC nº 1.538/2004, Dia do Evangélico - * @see Based on: https://pt.wikipedia.org/wiki/Acre Lei AC nº 1.411/2001, Dia Internacional da Mulher - * @see Based on: https://pt.wikipedia.org/wiki/Acre Lei AC nº 14/1964, Aniversário do Acre - * @see Based on: https://pt.wikipedia.org/wiki/Acre Lei AC nº 1.526/2004, Dia da Amazônia - * @see Based on: https://pt.wikipedia.org/wiki/Acre Lei AC nº 57/1965, Assinatura do Tratado de Petrópolis - * @see Based on: https://pt.wikipedia.org/wiki/Alagoas Lei AL nº 5.508/1993, São João - * @see Based on: https://pt.wikipedia.org/wiki/Alagoas Lei AL nº 5.509/1993, São Pedro - * @see Based on: https://pt.wikipedia.org/wiki/Feriados_no_Brasil Decreto AL nº 68.782/2019 (ponto facultativo), Emancipação Política de Alagoas - * @see Based on: https://pt.wikipedia.org/wiki/Feriados_no_Brasil Lei AP nº 667/2002, Dia de São José - * @see Based on: https://pt.wikipedia.org/wiki/Amap%C3%A1 Constituição Estadual do AP, Criação do Território Federal do Amapá - * @see Based on: https://pt.wikipedia.org/wiki/Dia_Nacional_de_Zumbi_e_da_Consci%C3%AAncia_Negra Lei AP nº 1.169/2007, Dia Estadual da Consciência Negra (state holiday until it became national in 2024) - * @see Official: https://sapl.al.am.leg.br/norma/8919 Lei AM nº 25/1977, Elevação do Amazonas à categoria de Província (05/09) - * @see Official: https://sapl.al.am.leg.br/norma/2873 Lei AM nº 84/2010, Dia da Consciência Negra (state holiday until it became national in 2024) - * @see Based on: https://www.legisweb.com.br/legislacao/?id=316229 Decreto AM de 02/02/2016 (calendário oficial), Nossa Senhora da Conceição (08/12): ponto facultativo estadual; feriado apenas no Município de Manaus (Lei Municipal nº 496/1999) - * @see Based on: https://pt.wikipedia.org/wiki/Feriados_no_Brasil Constituição Estadual da BA, Independência da Bahia - * @see Based on: https://pt.wikipedia.org/wiki/Cear%C3%A1 Constituição Estadual do CE (Data Magna), Abolição da Escravidão no Ceará - * @see Based on: https://pt.wikipedia.org/wiki/Feriados_no_Brasil Lei Distrital nº 963/1995, Dia do Evangélico (DF) - * @see Based on: https://pt.wikipedia.org/wiki/Feriados_no_Brasil Fundação de Brasília (21/4, Lei Orgânica do DF) - * @see Based on: https://pt.wikipedia.org/wiki/Esp%C3%ADrito_Santo_(estado) Lei ES nº 11.010/2019, Nossa Senhora da Penha (padroeira do estado, 8º dia após a Páscoa) - * @see Based on: https://pt.wikipedia.org/wiki/Feriados_no_Brasil Lei MA nº 2.457/1964, Adesão do Maranhão à Independência - * @see Official: https://www.al.mt.gov.br/norma-juridica/urn:lex:br;mato.grosso:estadual:lei.ordinaria:2002-12-27;7879 Lei MT nº 7.879/2002, Dia da Consciência Negra (state holiday until it became national in 2024) - * @see Based on: https://pt.wikipedia.org/wiki/Feriados_no_Brasil Lei MS nº 10/1979, Criação do Estado de Mato Grosso do Sul - * @see Based on: https://pt.wikipedia.org/wiki/Feriados_no_Brasil Lei PA nº 5.999/1996, Adesão do Pará à Independência - * @see Based on: https://pt.wikipedia.org/wiki/Para%C3%ADba Lei PB nº 10.601/2015, Fundação do Estado e Dia de Nossa Senhora das Neves - * @see Based on: https://pt.wikipedia.org/wiki/Para%C3%ADba Lei PB nº 3.489/1967, art. 2º, Morte de João Pessoa - * @see Based on: https://pt.wikipedia.org/wiki/Paran%C3%A1 Lei PR nº 18.384/2014 (ponto facultativo), Emancipação Política do Paraná - * @see Official: https://legis.alepe.pe.gov.br/texto.aspx?ano=2017&complemento=0&numero=16059&tipo=&tiponorma=1&url= Lei PE nº 16.059/2017, Revolução Pernambucana (Data Magna, fixed 6 March; supersedes the movable "primeiro domingo de março" date set by Lei PE nº 13.835/2009) - * @see Based on: https://pt.wikipedia.org/wiki/Feriados_no_Brasil Lei PI nº 176/1937, Dia do Piauí - * @see Official: http://alerjln1.alerj.rj.gov.br/CONTLEI.NSF/c8aa0900025feef6032564ec0060dfff/1baf90ca125ff96f8325740a00776600 Lei RJ nº 5.198/2008, São Jorge - * @see Official: http://alerjln1.alerj.rj.gov.br/CONTLEI.NSF/69d90307244602bb032567e800668618/80a541c3a5a9d63183256c7d0057bf25 Lei RJ nº 4.007/2002, Dia da Consciência Negra (state holiday until it became national in 2024; ADI 4.131 pending at the STF) - * @see Based on: https://pt.wikipedia.org/wiki/Feriados_no_Brasil Lei RN nº 8.913/2006, Mártires de Cunhaú e Uruaçu - * @see Based on: https://pt.wikipedia.org/wiki/Feriados_no_Brasil Constituição Estadual do RS, Revolução Farroupilha - * @see Based on: https://pt.wikipedia.org/wiki/Feriados_no_Brasil Lei RO nº 3.170/2013, Criação do Estado de Rondônia - * @see Based on: https://pt.wikipedia.org/wiki/Feriados_no_Brasil Constituição Estadual de RR, Criação do Estado de Roraima - * @see Based on: https://pt.wikipedia.org/wiki/Santa_Catarina Lei SC nº 16.719/2015 (consolida e revoga as Leis nº 10.306/1996 e 12.906/2004), Criação da Capitania de Santa Catarina - * @see Based on: https://pt.wikipedia.org/wiki/Santa_Catarina Lei SC nº 16.719/2015, Dia de Santa Catarina de Alexandria - * @see Official: https://www.al.sp.gov.br/documentacao/estudos-e-manuais/feriado-9-julho/artigo.htm Lei SP nº 9.497/1997 (PL 710/1995), Revolução Constitucionalista - * @see Official: https://www.al.sp.gov.br/repositorio/legislacao/lei/2023/lei-17746-12.09.2023.html Lei SP nº 17.746/2023, Dia da Consciência Negra (state holiday in 2023, national since 2024) - * @see Based on: https://pt.wikipedia.org/wiki/Feriados_no_Brasil Constituição Estadual de SE, Emancipação Política de Sergipe - * @see Based on: https://pt.wikipedia.org/wiki/Feriados_no_Brasil Lei TO nº 960/1998, Autonomia do Estado do Tocantins - * @see Based on: https://pt.wikipedia.org/wiki/Feriados_no_Brasil Lei TO nº 627/1993, Padroeira do Estado (Nossa Senhora da Natividade) - * @see Based on: https://pt.wikipedia.org/wiki/Feriados_no_Brasil Lei TO nº 98/1989, Criação do Estado do Tocantins + * Only one of these is a feriado civil under art. 1º, II of Lei 9.093/1995, which authorizes + * "a data magna do Estado fixada em lei estadual", in the singular. The remaining entries rest + * on ordinary state laws (and, for a few states, on the state constitution) that declare further + * feriados estaduais; the library reports them because they are observed in practice, not + * because art. 1º, II covers them. + * + * The statutory date is what is emitted. Three states shift the observed date and only Santa + * Catarina's shift is modelled here (`nextSundayWhenWeekday`, from + * `SC_ALEXANDRIA_TRANSFER_SINCE_YEAR` on for 25 November, apart from the + * `SC_ALEXANDRIA_TRANSFER_GAP_YEAR` gap, and from `SC_NEXT_SUNDAY_TRANSFER_SINCE_YEAR` on for + * 11 August): Acre moves feriados falling from + * Tuesday to Thursday on to the following Friday (Lei AC nº 2.126/2009, except 15/06), and the + * Goiás executive may move 26/07 and 28/10 to a nearby dia útil by decree (Lei GO nº 20.756/2020, + * art. 269, § 1º), neither of which can be resolved from a year alone. + * + * @see Official: https://legis.ac.gov.br/detalhar/1087 + * Lei AC nº 1.538/2004, Dia do Evangélico (23/01) + * @see Official: https://legis.ac.gov.br/detalhar/1828 + * Lei AC nº 1.411/2001, Dia Internacional da Mulher (08/03) + * @see Official: https://legis.ac.gov.br/detalhar/618 + * Lei AC nº 14/1964, Aniversário do Acre (15/06) + * @see Official: https://legis.ac.gov.br/detalhar/940 + * Lei AC nº 243/1968, art. 2º, Dia da Amazônia (05/09): "É considerado feriado estadual o dia 5 de + * setembro em homenagem ao DIA DA AMAZÔNIA". Lei AC nº 1.526/2004, cited here before, only adds + * the date to the calendário oficial de eventos. + * @see Official: https://legis.ac.gov.br/detalhar/688 + * Lei AC nº 57/1965, Assinatura do Tratado de Petrópolis (17/11) + * @see Official: https://sapl.al.al.leg.br/norma/3363 + * Lei AL nº 5.508/1993, São João (24/06) + * @see Official: https://sapl.al.al.leg.br/norma/3364 + * Lei AL nº 5.509/1993, São Pedro (29/06) + * @see Official: https://sapl.al.al.leg.br/norma/3117 + * Lei AL nº 9.358, de 26/08/2024, Emancipação Política de Alagoas (16/09): "DISPÕE SOBRE O FERIADO + * ESTADUAL DA EMANCIPAÇÃO POLÍTICA DO ESTADO DE ALAGOAS - DIA 16 DE SETEMBRO". Until 2023 the date + * was only the ponto facultativo of the Decreto AL nº 68.782/2019. + * @see Official: https://al.ap.leg.br/ver_texto_lei.php?iddocumento=17488 + * Lei AP nº 667/2002, art. 1º par. único, Dia de São José (19/03) + * @see Official: https://silegis.al.ap.leg.br/proposicaopdf/2CEatualizadaeconsolidadaateEC071comSumario.pdf + * Constituição Estadual do AP, art. 355, Criação do Território Federal do Amapá (13/09): "O dia 13 + * de Setembro, data magna do Amapá, é feriado em todo o território do Estado". + * @see Official: https://al.ap.leg.br/ver_texto_lei.php?iddocumento=22214 + * Lei AP nº 1.169/2007, Dia Estadual da Consciência Negra (state holiday until it became national + * in 2024) + * @see Official: https://sapl.al.am.leg.br/norma/8919 + * Lei AM nº 25/1977, Elevação do Amazonas à categoria de Província (05/09) + * @see Official: https://sapl.al.am.leg.br/norma/2873 + * Lei AM nº 84/2010, Dia da Consciência Negra (state holiday until it became national in 2024) + * @see Official: https://sapl.cmm.am.gov.br/norma/3932 + * Lei Municipal de Manaus nº 496/1999, Nossa Senhora da Conceição (08/12): "INSTITUI feriado + * religioso no Município de Manaus no dia 8 de dezembro". No state norm declaring 08/12 was + * located in the ALEAM records, so the entry is reported as an optional day, not as a feriado + * estadual. + * @see Official: https://www.legislabahia.ba.gov.br/documentos/constituicao-do-estado-da-bahia-de-05-de-outubro-de-1989 + * Constituição Estadual da BA, art. 6º § 3º, Independência da Bahia (02/07): "O Dois de Julho, + * data magna da Bahia ..., é feriado em todo o território do Estado". + * @see Official: https://belt.al.ce.gov.br/index.php/constituicao-do-ceara/emendas-a-constituicao-do-ceara/item/5643-emenda-constitucional-n-73-de-1-de-dezembro-de-2011-d-o-06-12-11 + * Constituição Estadual do CE, art. 18 par. único (EC nº 73/2011), Abolição da Escravidão no Ceará + * (25/03): the text fixes the data magna, and the feriado follows from Lei 9.093/1995, art. 1º, + * II. + * @see Official: https://www.sinj.df.gov.br/sinj/Norma/18459/Lei_72_27_12_1989.html + * Lei distrital nº 72/1989, art. 1º, I, Fundação de Brasília (21/04), and art. 1º par. único, + * Corpus Christi: "São, igualmente feriados, a Sexta-feira da Paixão e Corpus Christi, datas + * móveis". + * @see Official: https://www.sinj.df.gov.br/sinj/Norma/48922/Lei_963_1995.html + * Lei distrital nº 963/1995, Dia do Evangélico (30/11) + * @see Official: https://www3.al.es.gov.br/Arquivo/Documents/legislacao/html/LEI110102019.html + * Lei ES nº 11.010/2019, art. 1º par. único, Nossa Senhora da Penha (padroeira do estado, "sempre + * na segunda-feira, oitavo dia posterior ao domingo de Páscoa") + * @see Official: https://legisla.casacivil.go.gov.br/pesquisa_legislacao/100979/lei-20756 + * Lei GO nº 20.756/2020, art. 269, II, the three feriados estaduais of Goiás: "a) 26 de julho, + * consagrado à fundação da cidade de Goiás; b) 24 de outubro, comemorativo ao lançamento da pedra + * fundamental de Goiânia; c) 28 de outubro, consagrado ao servidor público". + * @see Official: https://arquivos.al.ma.leg.br:8443/ged/legislacao/LEI_2457 + * Lei MA nº 2.457/1964, Adesão do Maranhão à Independência (28/07) + * @see Official: https://www.al.mt.gov.br/norma-juridica/urn:lex:br;mato.grosso:estadual:lei.ordinaria:2002-12-27;7879 + * Lei MT nº 7.879/2002, Dia da Consciência Negra (state holiday until it became national in 2024) + * @see Official: https://aacpdappls.net.ms.gov.br/appls/legislacao/secoge/govato.nsf/1b758e65922af3e904256b220050342a/a489a293563f506304256e450002e9f8 + * Lei MS nº 10/1979, Criação do Estado de Mato Grosso do Sul (11/10) + * @see Official: https://bancodeleis.alepa.pa.gov.br/arquivos/lei5999_1996_93239.pdf + * Lei PA nº 5.999/1996, Adesão do Pará à Independência (15/08) + * @see Official: https://sapl.al.pb.leg.br/norma/11988 + * Lei PB nº 10.601/2015, Data Magna do Estado da Paraíba (05/08): "INSTITUI COMO FERIADO CIVIL O + * DIA 05 DE AGOSTO, DATA MAGNA DO ESTADO DA PARAÍBA". Its art. 2º also revoked art. 2º of Lei PB + * nº 3.489/1967, the basis of the 26/07 Morte de João Pessoa entry, which is therefore emitted + * only up to 2015. + * @see Official: https://www.legislacao.pr.gov.br/legislacao/pesquisarAto.do?action=exibir&codAto=134573 + * Lei PR nº 18.384/2014, Emancipação Política do Paraná (19/12), expressly "não se constituindo em + * feriado civil" + * @see Official: https://legis.alepe.pe.gov.br/texto.aspx?tiponorma=1&numero=16241&complemento=0&ano=2017&tipo=&url= + * Lei PE nº 16.241/2017, art. 49, Revolução Pernambucana (06/03): "Dia 6 de março: Data Magna do + * Estado de Pernambuco e feriado civil no âmbito do Estado de Pernambuco". Revoked the Lei PE nº + * 16.059/2017 cited here before, which had itself superseded the movable "primeiro domingo de + * março" of Lei PE nº 13.835/2009. + * @see Official: https://sapl.al.pi.leg.br/norma/5849 + * Lei PI nº 176/1937, Dia do Piauí (19/10) + * @see Official: http://alerjln1.alerj.rj.gov.br/CONTLEI.NSF/c8aa0900025feef6032564ec0060dfff/1baf90ca125ff96f8325740a00776600 + * Lei RJ nº 5.198/2008, São Jorge (23/04): the ALERJ text of the law. Its Ficha Técnica records + * no ação de inconstitucionalidade; the STF case is cited separately below. + * @see Official: https://portal.stf.jus.br/processos/detalhe.asp?incidente=2624787 + * STF ADI 4092, which upheld that law. Decisão de julgamento of 28/08/2023, Tribunal Pleno, + * sessão virtual: "O Tribunal, por maioria, declarou a constitucionalidade da Lei do Estado do Rio + * de Janeiro n. 5.198, de 5 de março de 2008, e, por conseguinte, julgou improcedente a presente + * ação direta … Plenário, Sessão Virtual de 18.8.2023 a 25.8.2023"; trânsito em julgado 28/10/2023. + * @see Official: http://alerjln1.alerj.rj.gov.br/CONTLEI.NSF/69d90307244602bb032567e800668618/80a541c3a5a9d63183256c7d0057bf25 + * Lei RJ nº 4.007/2002, Dia da Consciência Negra (state holiday until it became national in 2024). + * Its Ficha Técnica records no ação de inconstitucionalidade either. + * @see Official: https://portal.stf.jus.br/processos/detalhe.asp?incidente=2636281 + * STF ADI 4131, cited here before as pending against Lei RJ nº 4.007/2002, in fact sought "a + * declaração de inconstitucionalidade da Lei n. 5.243, do Estado do Rio de Janeiro, de 14 de maio + * de 2008" and was não conhecida on 21/09/2018 (trânsito em julgado 25/10/2018). + * @see Official: http://www.al.rn.leg.br/storage/legislacao//arq5064574f632ec.pdf + * Lei RN nº 8.913/2006, Mártires de Cunhaú e Uruaçu (03/10) + * @see Official: https://ww2.al.rs.gov.br/dal/LinkClick.aspx?fileticket=WQdIfqNoXO4%3d&tabid=3683&mid=5359 + * Constituição Estadual do RS compilada (the "Veja em HTML" document of the Assembleia's + * Constituição Estadual page, linked below), art. 6º § 1º, Revolução Farroupilha (20/09): "O dia + * 20 de setembro é a data magna, sendo considerado feriado no Estado. (Redação dada pela Emenda + * Constitucional n.º 11, de 03/10/95) … (Renumerado pela Emenda Constitucional n.º 83, de + * 28/09/23)". + * @see Official: https://ww2.al.rs.gov.br/dal/Legisla%C3%A7%C3%A3o/Constitui%C3%A7%C3%A3oEstadual/tabid/3683/Default.aspx + * The Assembleia Legislativa do RS page that publishes that compiled text; it is a link hub and + * carries no article text of its own. + * @see Official: https://sapl.al.ro.leg.br/norma/4958 + * Lei RO nº 2.291, de 22/04/2010, Criação do Estado de Rondônia (04/01): "DECLARA O DIA 4 DE + * JANEIRO DATA MAGNA E FERIADO CIVIL ESTADUAL". Lei RO nº 3.170/2013, cited here before, is a + * supplementary credit law unrelated to holidays. + * @see Official: http://sapl.al.rr.leg.br/media/sapl/public/normajuridica/1991/3912/constituicao_estadual_do_estado_de_roraima.pdf + * Constituição Estadual de RR, art. 9º, Criação do Estado de Roraima (05/10): "Cinco de outubro, + * data magna de Roraima, é feriado em todo o território do Estado". + * @see Official: http://leis.alesc.sc.gov.br/html/2022/18531_2022_lei.html + * Lei SC nº 18.531/2022, the in-force consolidation, whose Anexo Único carries both Santa Catarina + * holidays and the Sunday transfer: "Sempre que o dia 11 de agosto coincidir com dia útil da + * semana, o feriado e os eventos alusivos à data serão transferidos para o domingo subsequente" + * and the same clause for 25 de novembro. + * @see Official: http://leis.alesc.sc.gov.br/html/1996/10306_1996_lei.html + * Lei SC nº 10.306/1996, art. 1º, in the wording of Lei SC nº 12.906/2004: "É considerada data + * magna do Estado o dia 11 de agosto, Dia do Estado de Santa Catarina, e dia de Santa Catarina de + * Alexandria, dia 25 de novembro". + * @see Official: http://leis.alesc.sc.gov.br/html/1999/11213_1999_lei.html + * Lei SC nº 11.213, de 11 de novembro de 1999, which added to art. 1º of Lei SC nº 10.306/1996 the + * parágrafo único transferring 25 November alone: "Sempre que o dia 25 de novembro coincidir com + * dia útil da semana, o feriado e os eventos alusivos à data serão transferidos para o domingo + * subseqüente". Its art. 2º put it in force on the day it was published (DO 16.290, de 12/11/1999), + * thirteen days before that year's 25 November, so the 25 November transfer starts in 1999 and not + * in 2005. The Anexo of the in-force Lei SC nº 18.531/2022 credits the same clause to "10.306, de + * 1996; 11.213, de 1999 e 12.906, de 2004". + * @see Official: http://leis.alesc.sc.gov.br/html/2004/12906_2004_lei.html + * Lei SC nº 12.906, de 22 de janeiro de 2004, which added 11 August to the caput of art. 1º of Lei + * SC nº 10.306/1996 and, by its art. 3º, "Revoga-se a Lei nº 11.213, de 11 de novembro de 1999" + * without restating the transfer clause. It entered into force on the day it was published (DO + * 17.320, de 22/01/2004), before that year's 25 November, so 2004 is the one year in which neither + * date is transferred. + * @see Official: http://leis.alesc.sc.gov.br/html/2005/13408_2005_lei.html + * Lei SC nº 13.408, de 15/07/2005, which reinstated the parágrafo único, this time transferring + * both dates to the following Sunday, and, by its art. 2º, entered into force on the day it was + * published (DO 17.680, de 15/07/2005): "Sempre que o dia 11 de agosto e o dia 25 de novembro + * coincidirem com dias úteis da semana, os feriados e os eventos alusivos às datas serão + * transferidos para o domingo subseqüente". Both of that year's dates fall after it. The two + * holidays are therefore split by year: 11 August is fixed up to 2004 and transferring from 2005 + * on, while 25 November is fixed up to 1998, transferring from 1999 to 2003, fixed again in 2004 + * and transferring from 2005 on. Lei SC nº 16.719/2015, cited here before, was revoked by Lei SC nº + * 17.335/2017, itself consolidated and revoked by Lei SC nº 18.531/2022. + * @see Official: https://www.al.sp.gov.br/documentacao/estudos-e-manuais/feriado-9-julho/artigo.htm + * Lei SP nº 9.497/1997 (PL 710/1995), Revolução Constitucionalista (09/07) + * @see Official: https://www.al.sp.gov.br/repositorio/legislacao/lei/2023/lei-17746-12.09.2023.html + * Lei SP nº 17.746/2023, Dia da Consciência Negra: a permanent state holiday, listed here only for + * 2023 because the national holiday of Lei 14.759/2023 takes over from 2024. + * @see Official: https://aleselegis.al.se.leg.br/Arquivo/Documents/legislacao/html/CE11989.html + * Constituição Estadual de SE, art. 269 (EC nº 20/2000), Independência de Sergipe (08/07): "Será + * feriado estadual o dia 08 de julho, data consagrada à Independência de Sergipe". + * @see Official: https://www.al.to.leg.br/arquivo/15717 + * Lei TO nº 960/1998, whose art. 1º caput only institutes the Dia da Autonomia (18/03); the + * feriado estadual sat in the parágrafo único. + * @see Official: https://www.al.to.leg.br/arquivo/15724 + * Lei TO nº 2.013, de 18/02/2009, which replaced that parágrafo único with a purely commemorative + * provision, so 18/03 is emitted only up to 2008. + * @see Official: https://www.al.to.leg.br/arquivo/6883 + * Lei TO nº 627/1993, Padroeira do Estado (Nossa Senhora da Natividade, 08/09) + * @see Official: https://www.al.to.leg.br/arquivo/6358 + * Lei TO nº 98/1989, Criação do Estado do Tocantins (05/10) */ export const STATE_HOLIDAYS: Partial> = { AC: [ @@ -85,7 +260,19 @@ export const STATE_HOLIDAYS: Partial> = { AL: [ { name: "São João", day: 24, month: 6 }, { name: "São Pedro", day: 29, month: 6 }, - { name: "Emancipação Política de Alagoas", day: 16, month: 9, type: "optional" }, + { + name: "Emancipação Política de Alagoas", + day: 16, + month: 9, + type: "optional", + until: AL_EMANCIPACAO_FERIADO_SINCE_YEAR, + }, + { + name: "Emancipação Política de Alagoas", + day: 16, + month: 9, + since: AL_EMANCIPACAO_FERIADO_SINCE_YEAR, + }, ], AP: [ { name: "Dia de São José", day: 19, month: 3 }, @@ -113,9 +300,15 @@ export const STATE_HOLIDAYS: Partial> = { CE: [{ name: "Abolição da Escravidão no Ceará", day: 25, month: 3 }], DF: [ { name: "Fundação de Brasília", day: 21, month: 4 }, + { name: "Corpus Christi", easterOffset: 60 }, { name: "Dia do Evangélico", day: 30, month: 11 }, ], ES: [{ name: "Nossa Senhora da Penha", easterOffset: 8 }], + GO: [ + { name: "Fundação da Cidade de Goiás", day: 26, month: 7 }, + { name: "Lançamento da Pedra Fundamental de Goiânia", day: 24, month: 10 }, + { name: "Dia do Servidor Público", day: 28, month: 10 }, + ], MA: [{ name: "Adesão do Maranhão à Independência", day: 28, month: 7 }], MT: [ { @@ -128,12 +321,13 @@ export const STATE_HOLIDAYS: Partial> = { MS: [{ name: "Criação do Estado de Mato Grosso do Sul", day: 11, month: 10 }], PA: [{ name: "Adesão do Pará à Independência", day: 15, month: 8 }], PB: [ + { name: "Data Magna do Estado da Paraíba", day: 5, month: 8 }, { - name: "Fundação do Estado e Dia de Nossa Senhora das Neves", - day: 5, - month: 8, + name: "Morte de João Pessoa", + day: 26, + month: 7, + until: PB_MORTE_JOAO_PESSOA_UNTIL_YEAR, }, - { name: "Morte de João Pessoa", day: 26, month: 7 }, ], PR: [{ name: "Emancipação Política do Paraná", day: 19, month: 12, type: "optional" }], PE: [{ name: "Revolução Pernambucana", day: 6, month: 3 }], @@ -152,8 +346,47 @@ export const STATE_HOLIDAYS: Partial> = { RO: [{ name: "Criação do Estado de Rondônia", day: 4, month: 1 }], RR: [{ name: "Criação do Estado de Roraima", day: 5, month: 10 }], SC: [ - { name: "Criação da Capitania de Santa Catarina", day: 11, month: 8 }, - { name: "Dia de Santa Catarina de Alexandria", day: 25, month: 11 }, + { + name: "Dia do Estado de Santa Catarina", + day: 11, + month: 8, + until: SC_NEXT_SUNDAY_TRANSFER_SINCE_YEAR, + }, + { + name: "Dia do Estado de Santa Catarina", + day: 11, + month: 8, + nextSundayWhenWeekday: true, + since: SC_NEXT_SUNDAY_TRANSFER_SINCE_YEAR, + }, + { + name: "Dia de Santa Catarina de Alexandria", + day: 25, + month: 11, + until: SC_ALEXANDRIA_TRANSFER_SINCE_YEAR, + }, + { + name: "Dia de Santa Catarina de Alexandria", + day: 25, + month: 11, + nextSundayWhenWeekday: true, + since: SC_ALEXANDRIA_TRANSFER_SINCE_YEAR, + until: SC_ALEXANDRIA_TRANSFER_GAP_YEAR, + }, + { + name: "Dia de Santa Catarina de Alexandria", + day: 25, + month: 11, + since: SC_ALEXANDRIA_TRANSFER_GAP_YEAR, + until: SC_NEXT_SUNDAY_TRANSFER_SINCE_YEAR, + }, + { + name: "Dia de Santa Catarina de Alexandria", + day: 25, + month: 11, + nextSundayWhenWeekday: true, + since: SC_NEXT_SUNDAY_TRANSFER_SINCE_YEAR, + }, ], SP: [ { name: "Revolução Constitucionalista", day: 9, month: 7 }, @@ -165,9 +398,14 @@ export const STATE_HOLIDAYS: Partial> = { until: CONSCIENCIA_NEGRA_NATIONAL_SINCE_YEAR, }, ], - SE: [{ name: "Emancipação Política de Sergipe", day: 8, month: 7 }], + SE: [{ name: "Independência de Sergipe", day: 8, month: 7 }], TO: [ - { name: "Autonomia do Estado do Tocantins", day: 18, month: 3 }, + { + name: "Autonomia do Estado do Tocantins", + day: 18, + month: 3, + until: TO_AUTONOMIA_UNTIL_YEAR, + }, { name: "Padroeira do Estado (Nossa Senhora da Natividade)", day: 8, diff --git a/src/get-holidays/get-holidays.test.ts b/src/get-holidays/get-holidays.test.ts index f125e0ce..b718bc54 100644 --- a/src/get-holidays/get-holidays.test.ts +++ b/src/get-holidays/get-holidays.test.ts @@ -7,6 +7,10 @@ import { isBusinessDay } from "../is-business-day/is-business-day"; import { STATE_HOLIDAYS } from "./constants"; import { getHolidays, type GetHolidaysOptions, type Holiday } from "./get-holidays"; +const PROTOTYPE_KEYS = Object.getOwnPropertyNames(Object.prototype); + +const hostileStateCodes = fc.constantFrom(...PROTOTYPE_KEYS, "SP", "xx"); + function getHolidaysFor(year: number, stateCode: StateCode | null): Holiday[] { return stateCode === null ? getHolidays(year) : getHolidays({ year, stateCode }); } @@ -340,21 +344,21 @@ describe("getHolidays", () => { ).toHaveLength(1); }); - test("should include state holidays added after the 2026 legal audit: PB's Morte de João Pessoa (Lei nº 3.489/1967, art. 2º), TO's Autonomia do Estado do Tocantins (Lei nº 960/1998), and AP's Dia Estadual da Consciência Negra (Lei nº 1.169/2007, until superseded by the 2024 national holiday)", () => { - const pbHolidays = getHolidays({ year: 2024, stateCode: "PB" }); - const toHolidays = getHolidays({ year: 2024, stateCode: "TO" }); + test("should include state holidays added after the 2026 legal audit while they were in force: PB's Morte de João Pessoa (Lei nº 3.489/1967, art. 2º), TO's Autonomia do Estado do Tocantins (Lei nº 960/1998), and AP's Dia Estadual da Consciência Negra (Lei nº 1.169/2007, until superseded by the 2024 national holiday)", () => { + const pbHolidays = getHolidays({ year: 2015, stateCode: "PB" }); + const toHolidays = getHolidays({ year: 2008, stateCode: "TO" }); const apHolidays2023 = getHolidays({ year: 2023, stateCode: "AP" }); const apHolidays2024 = getHolidays({ year: 2024, stateCode: "AP" }); expect(pbHolidays).toContainEqual({ name: "Morte de João Pessoa", - date: new Date(2024, 6, 26), + date: new Date(2015, 6, 26), type: "state", }); expect(toHolidays).toContainEqual({ name: "Autonomia do Estado do Tocantins", - date: new Date(2024, 2, 18), + date: new Date(2008, 2, 18), type: "state", }); @@ -367,6 +371,224 @@ describe("getHolidays", () => { expect(apHolidays2024.some((h) => h.name === "Dia Estadual da Consciência Negra")).toBe(false); }); + test("should stop emitting PB's Morte de João Pessoa from 2016 on, since Lei PB nº 10.601/2015 art. 2º revoked art. 2º of Lei PB nº 3.489/1967 on 17/12/2015", () => { + expect( + getHolidays({ year: 2016, stateCode: "PB" }).some((h) => h.name === "Morte de João Pessoa"), + ).toBe(false); + + expect(getHolidays({ year: 2016, stateCode: "PB" })).toContainEqual({ + name: "Data Magna do Estado da Paraíba", + date: new Date(2016, 7, 5), + type: "state", + }); + }); + + test("should stop emitting TO's Autonomia do Estado do Tocantins from 2009 on, since Lei TO nº 2.013/2009 rewrote the parágrafo único of Lei TO nº 960/1998 art. 1º, the only clause that declared the feriado, into a commemorative provision", () => { + expect( + getHolidays({ year: 2009, stateCode: "TO" }).some( + (h) => h.name === "Autonomia do Estado do Tocantins", + ), + ).toBe(false); + + expect(getHolidays({ year: 2009, stateCode: "TO" })).toContainEqual({ + name: "Criação do Estado do Tocantins", + date: new Date(2009, 9, 5), + type: "state", + }); + }); + + test("should type AL's 16 September as a feriado estadual from 2024 on (Lei AL nº 9.358/2024) and as an optional day before it (Decreto AL nº 68.782/2019)", () => { + expect(getHolidays({ year: 2023, stateCode: "AL" })).toContainEqual({ + name: "Emancipação Política de Alagoas", + date: new Date(2023, 8, 16), + type: "optional", + }); + + expect(getHolidays({ year: 2024, stateCode: "AL" })).toContainEqual({ + name: "Emancipação Política de Alagoas", + date: new Date(2024, 8, 16), + type: "state", + }); + + expect( + getHolidays({ year: 2024, stateCode: "AL" }).filter( + (h) => h.name === "Emancipação Política de Alagoas", + ), + ).toHaveLength(1); + }); + + test("should list the three Goiás state holidays of Lei GO nº 20.756/2020, art. 269, II", () => { + const holidays = getHolidays({ year: 2024, stateCode: "GO" }); + + expect(holidays).toContainEqual({ + name: "Fundação da Cidade de Goiás", + date: new Date(2024, 6, 26), + type: "state", + }); + expect(holidays).toContainEqual({ + name: "Lançamento da Pedra Fundamental de Goiânia", + date: new Date(2024, 9, 24), + type: "state", + }); + expect(holidays).toContainEqual({ + name: "Dia do Servidor Público", + date: new Date(2024, 9, 28), + type: "state", + }); + }); + + test("should replace the national optional Corpus Christi with a DF state entry, which Lei distrital nº 72/1989 art. 1º parágrafo único declares a feriado, without listing the date twice", () => { + const dfHolidays = getHolidays({ year: 2024, stateCode: "DF" }); + const nationalHolidays = getHolidays(2024); + + expect(dfHolidays.filter((h) => h.name === "Corpus Christi")).toEqual([ + { name: "Corpus Christi", date: new Date(2024, 4, 30), type: "state" }, + ]); + + expect(nationalHolidays).toContainEqual({ + name: "Corpus Christi", + date: new Date(2024, 4, 30), + type: "optional", + }); + + expect(getHolidays({ year: 2024, stateCode: "SP" })).toContainEqual({ + name: "Corpus Christi", + date: new Date(2024, 4, 30), + type: "optional", + }); + }); + + test("should list DF's Fundação de Brasília (Lei distrital nº 72/1989, art. 1º, I) next to the national Tiradentes, which falls on the same 21 April under a different name", () => { + const dfHolidays = getHolidays({ year: 2024, stateCode: "DF" }); + + expect(dfHolidays).toContainEqual({ + name: "Fundação de Brasília", + date: new Date(2024, 3, 21), + type: "state", + }); + expect(dfHolidays).toContainEqual({ + name: "Tiradentes", + date: new Date(2024, 3, 21), + type: "national", + }); + }); + + test("should move both Santa Catarina holidays to the following Sunday when they fall Monday to Friday, as Lei SC nº 18.531/2022 requires (11/08/2025 is a Monday, 25/11/2025 a Tuesday)", () => { + const holidays = getHolidays({ year: 2025, stateCode: "SC" }); + + expect(holidays).toContainEqual({ + name: "Dia do Estado de Santa Catarina", + date: new Date(2025, 7, 17), + type: "state", + }); + expect(holidays).toContainEqual({ + name: "Dia de Santa Catarina de Alexandria", + date: new Date(2025, 10, 30), + type: "state", + }); + }); + + test("should keep both Santa Catarina holidays on their statutory date in a year they already fall on a weekend (11/08/2024 is a Sunday, 25/11/2029 a Sunday and 25/11/2028 a Saturday)", () => { + expect(getHolidays({ year: 2024, stateCode: "SC" })).toContainEqual({ + name: "Dia do Estado de Santa Catarina", + date: new Date(2024, 7, 11), + type: "state", + }); + expect(getHolidays({ year: 2029, stateCode: "SC" })).toContainEqual({ + name: "Dia de Santa Catarina de Alexandria", + date: new Date(2029, 10, 25), + type: "state", + }); + expect(getHolidays({ year: 2028, stateCode: "SC" })).toContainEqual({ + name: "Dia de Santa Catarina de Alexandria", + date: new Date(2028, 10, 25), + type: "state", + }); + }); + + test("should keep the Santa Catarina 11 August holiday on its statutory weekday before 2005, the year Lei SC nº 13.408/2005 extended the transfer to it (11/08/2003 is a Monday)", () => { + expect(getHolidays({ year: 2003, stateCode: "SC" })).toContainEqual({ + name: "Dia do Estado de Santa Catarina", + date: new Date(2003, 7, 11), + type: "state", + }); + }); + + test("should keep the Santa Catarina 25 November holiday on its statutory weekday before 1999, the year Lei SC nº 11.213/1999 introduced its transfer (25/11/1998 is a Wednesday)", () => { + expect(getHolidays({ year: 1998, stateCode: "SC" })).toContainEqual({ + name: "Dia de Santa Catarina de Alexandria", + date: new Date(1998, 10, 25), + type: "state", + }); + }); + + test("should move the Santa Catarina 25 November holiday to the following Sunday from 1999 on, the year Lei SC nº 11.213, de 11/11/1999, entered into force thirteen days before it (25/11/1999 is a Thursday)", () => { + expect(getHolidays({ year: 1999, stateCode: "SC" })).toContainEqual({ + name: "Dia de Santa Catarina de Alexandria", + date: new Date(1999, 10, 28), + type: "state", + }); + }); + + test("should move the Santa Catarina 25 November holiday into the next month when the following Sunday falls there (25/11/2002 is a Monday, so the holiday lands on 01/12/2002)", () => { + expect(getHolidays({ year: 2002, stateCode: "SC" })).toContainEqual({ + name: "Dia de Santa Catarina de Alexandria", + date: new Date(2002, 11, 1), + type: "state", + }); + }); + + test("should keep the Santa Catarina 25 November holiday on its statutory weekday in 2004, the one year art. 3º of Lei SC nº 12.906/2004 left it without a transfer clause (25/11/2004 is a Thursday)", () => { + expect(getHolidays({ year: 2004, stateCode: "SC" })).toContainEqual({ + name: "Dia de Santa Catarina de Alexandria", + date: new Date(2004, 10, 25), + type: "state", + }); + }); + + test("should move the Santa Catarina 25 November holiday again from 2005 on, the year Lei SC nº 13.408/2005 reinstated the transfer (25/11/2005 is a Friday)", () => { + expect(getHolidays({ year: 2005, stateCode: "SC" })).toContainEqual({ + name: "Dia de Santa Catarina de Alexandria", + date: new Date(2005, 10, 27), + type: "state", + }); + }); + + test("should switch to the Sunday transfer exactly in 2005, the year Lei SC nº 13.408, de 15/07/2005, entered into force (11/08/2004 is a Wednesday and stays, 11/08/2005 a Thursday and moves to 14/08)", () => { + expect(getHolidays({ year: 2004, stateCode: "SC" })).toContainEqual({ + name: "Dia do Estado de Santa Catarina", + date: new Date(2004, 7, 11), + type: "state", + }); + expect(getHolidays({ year: 2005, stateCode: "SC" })).toContainEqual({ + name: "Dia do Estado de Santa Catarina", + date: new Date(2005, 7, 14), + type: "state", + }); + }); + + test("should list each Santa Catarina holiday exactly once in every year the four 25 November ranges and the two 11 August ranges border on", () => { + for (const year of [1998, 1999, 2003, 2004, 2005, 2025]) { + const names = getHolidays({ year, stateCode: "SC" }).map((holiday) => holiday.name); + + expect(names.filter((name) => name === "Dia do Estado de Santa Catarina")).toEqual([ + "Dia do Estado de Santa Catarina", + ]); + expect(names.filter((name) => name === "Dia de Santa Catarina de Alexandria")).toEqual([ + "Dia de Santa Catarina de Alexandria", + ]); + } + }); + + test("should treat a prototype chain key as an unknown stateCode instead of throwing", () => { + const nationalHolidays = getHolidays(2024); + + for (const stateCode of PROTOTYPE_KEYS) { + // @ts-expect-error: intentionally invalid input + expect(getHolidays({ year: 2024, stateCode })).toEqual(nationalHolidays); + } + }); + test("should compute ES's Nossa Senhora da Penha (Lei nº 11.010/2019) as a movable state holiday, 8 days after Easter Sunday, replacing the removed 'Dia do Estado do Espírito Santo' which was only a municipal ponto facultativo", () => { const holidays = getHolidays({ year: 2024, stateCode: "ES" }); @@ -436,9 +658,14 @@ describe("getHolidays", () => { ); }); - test("should never throw, regardless of the input", () => { + const anyYear = fc.oneof(yearArbitrary, fc.anything()); + const anyStateCode = fc.oneof(hostileStateCodes, stateCodeArbitrary, fc.anything()); + const hostileOptions = fc.record({ year: anyYear, stateCode: anyStateCode }); + const anyInput = fc.oneof(fc.anything(), hostileOptions); + + test("should never throw, regardless of the input, prototype chain state codes included", () => { fc.assert( - fc.property(fc.anything(), (value) => { + fc.property(anyInput, (value) => { expect(() => getHolidays(value as never)).not.toThrow(); }), ); diff --git a/src/get-holidays/get-holidays.ts b/src/get-holidays/get-holidays.ts index 6007793d..bc8313e8 100644 --- a/src/get-holidays/get-holidays.ts +++ b/src/get-holidays/get-holidays.ts @@ -9,6 +9,8 @@ import { STATE_HOLIDAYS, } from "./constants"; +export type { StateCode } from "../_internals/constants/states"; + /** The class a holiday returned by `getHolidays` falls into. */ export type HolidayType = "national" | "state" | "optional" | "religious"; @@ -79,22 +81,33 @@ const computeHolidays = (year: number, stateCode: StateCode | undefined): Holida }, ); - // Stryker disable next-line ConditionalExpression: when stateCode is undefined, STATE_HOLIDAYS[stateCode] resolves to undefined too, so the inner `if (stateHolidays)` already no-ops either way - if (stateCode !== undefined) { - const stateHolidays = STATE_HOLIDAYS[stateCode]; - if (stateHolidays) { - for (const entry of stateHolidays) { - const { name, type, since, until } = entry; - // Stryker disable next-line ConditionalExpression: `since` is undefined for most entries, and `year < undefined` is already always false, so the explicit `since !== undefined` guard never changes the outcome - if (since !== undefined && year < since) continue; - // Stryker disable next-line ConditionalExpression: `until` is undefined for most entries, and `year >= undefined` is already always false, so the explicit `until !== undefined` guard never changes the outcome - if (until !== undefined && year >= until) continue; - - holidays.push({ - name, - date: resolveStateHolidayDate(year, entry), - type: type ?? "state", - }); + // An own entry lookup, so a prototype chain key ("toString", "__proto__", ...) is an unknown + // state code like any other, and so is `undefined` when no state code was given. + const stateEntry = Object.entries(STATE_HOLIDAYS).find(([code]) => code === stateCode); + + if (stateEntry) { + for (const entry of stateEntry[1]) { + const { name, type, since, until } = entry; + // Stryker disable next-line ConditionalExpression: `since` is undefined for most entries, and `year < undefined` is already always false, so the explicit `since !== undefined` guard never changes the outcome + if (since !== undefined && year < since) continue; + // Stryker disable next-line ConditionalExpression: `until` is undefined for most entries, and `year >= undefined` is already always false, so the explicit `until !== undefined` guard never changes the outcome + if (until !== undefined && year >= until) continue; + + const date = resolveStateHolidayDate(year, entry); + const stateHoliday: Holiday = { name, date, type: type ?? "state" }; + // Name and date together are the identity of a holiday here: a state entry only replaces + // a national one when both match, so DF's Corpus Christi replaces the national optional + // one while its Fundação de Brasília is listed next to Tiradentes, which falls on the + // same 21 April under a different name. + const stateHolidayKey = `${name}|${date.getTime()}`; + const nationalIndex = holidays.findIndex( + (holiday) => `${holiday.name}|${holiday.date.getTime()}` === stateHolidayKey, + ); + + if (nationalIndex === -1) { + holidays.push(stateHoliday); + } else { + holidays[nationalIndex] = stateHoliday; } } } @@ -117,7 +130,26 @@ const computeHolidays = (year: number, stateCode: StateCode | undefined): Holida * * If `stateCode` is provided but is not a valid/known state code, it is ignored and * only national holidays are returned (this mirrors passing no `stateCode` at all, - * and is kept for backwards compatibility). + * and is kept for backwards compatibility). The lookup is an own-property one, so a + * prototype-chain key such as `"__proto__"`, `"constructor"` or `"toString"` is an unknown + * state code like any other rather than a crash. + * + * When a state entry falls on the same date as a national one and carries the same name, the + * state entry replaces it instead of being listed twice: this is how the Distrito Federal's + * Corpus Christi, a feriado under Lei distrital nº 72/1989 art. 1º parágrafo único, comes back + * typed `"state"` for `stateCode: "DF"` while staying `"optional"` everywhere else. + * + * Only one state holiday per UF is a feriado civil under Lei 9.093/1995 art. 1º, II, which + * authorizes "a data magna do Estado fixada em lei estadual" in the singular; the other entries + * of `STATE_HOLIDAYS` rest on ordinary state laws and are reported because they are observed in + * practice. The date returned is the statutory one. Santa Catarina's two holidays are the only + * observance shift the table models: each moves to the following Sunday when it falls Monday to + * Friday, 11 August from 2005 on, when Lei SC nº 13.408/2005 extended the transfer to it, and + * 25 November from 1999 on, when Lei SC nº 11.213/1999 first introduced it, except in 2004, the + * year art. 3º of Lei SC nº 12.906/2004 left it without a transfer clause. Outside those ranges + * each holiday stays on 11 August or 25 November. Acre's Tuesday-to-Thursday shift and the Goiás decrees + * that may move 26/07 and 28/10 are not modelled, because neither can be resolved from a year + * alone. * * @param {number} year - The year for which to retrieve holidays (must be between 1900 and 2099) * @returns {Holiday[]} An array of holidays sorted by date @@ -131,23 +163,44 @@ const computeHolidays = (year: number, stateCode: StateCode | undefined): Holida * const spHolidays = getHolidays({ year: 2024, stateCode: 'SP' }); * ``` * - * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l0662.htm Lei 662/1949, the base - * national holidays law (Ano novo, Dia do trabalhador, Independência do Brasil, Proclamação da - * República, Natal). - * @see Official: https://www.planalto.gov.br/ccivil_03/leis/2002/l10607.htm Lei 10.607/2002, - * added Tiradentes and Finados to the national holidays. - * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l6802.htm Lei 6.802/1980, declared - * Nossa Senhora Aparecida (12 October) a national holiday. - * @see Official: https://www.planalto.gov.br/ccivil_03/_ato2023-2026/2023/lei/l14759.htm Lei - * 14.759/2023, nationalized Dia da Consciência Negra (20 November) from + * The national holiday laws are cited below; the state holiday laws are cited individually, one + * `@see` per holiday, in `src/get-holidays/constants.ts`. + * + * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l0662.htm + * Lei 662/1949, the base national holidays law (Ano novo, Dia do trabalhador, Independência do + * Brasil, Proclamação da República, Natal). + * @see Official: https://www.planalto.gov.br/ccivil_03/leis/2002/l10607.htm + * Lei 10.607/2002, rewrote that art. 1º into the list in force: it added Finados (2 November) + * to the national holidays and folded in Tiradentes (21 April), already national since art. 3º + * of Lei 1.266/1950, which its own art. 3º revoked. + * @see Official: https://www.planalto.gov.br/ccivil_03/leis/L1266.htm + * Lei 1.266/1950, art. 3º, which first made Tiradentes a national holiday: "É feriado nacional o + * dia 21 de abril, consagrado à glorificação de Tiradentes". Revoked by Lei 10.607/2002 only + * after that law had carried 21 April into Lei 662/1949. + * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l6802.htm + * Lei 6.802/1980, declared Nossa Senhora Aparecida (12 October) a national holiday. + * @see Official: https://www.planalto.gov.br/ccivil_03/_ato2023-2026/2023/lei/l14759.htm + * Lei 14.759/2023, nationalized Dia da Consciência Negra (20 November) from * `CONSCIENCIA_NEGRA_NATIONAL_SINCE_YEAR` (2024) onward. - * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l9093.htm Lei 9.093/1995, the - * framework law authorizing one state civil holiday and up to four municipal religious holidays; - * the legal basis for `STATE_HOLIDAYS`. - * @see Official: state holiday laws are cited individually, one `@see` per holiday, in - * `src/get-holidays/constants.ts`. - * @see Based on: https://pt.wikipedia.org/wiki/Feriados_no_Brasil Used as secondary evidence for - * some state holidays where no official law text was located (see constants.ts for which). + * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l9093.htm + * Lei 9.093/1995, the framework law authorizing one state civil holiday (art. 1º, II, "a data + * magna do Estado fixada em lei estadual") and up to four municipal religious holidays, "neste + * incluída a Sexta-Feira da Paixão" (art. 2º); the legal basis for the data magna entries of + * `STATE_HOLIDAYS`. + * @see Official: https://www.in.gov.br/web/dou/-/portaria-mgi-n-11.460-de-29-de-dezembro-de-2025-678388627 + * Portaria MGI nº 11.460/2025, the federal executive's annual calendar of feriados nacionais and + * pontos facultativos, reissued every December. It is the source of the typing of three of the + * four entries derived from Easter, which no federal law declares: "Paixão de Cristo (feriado + * nacional)" (Easter minus 2, emitted as `"Sexta-feira Santa"` typed `national`), "Carnaval (ponto + * facultativo)" (Easter minus 47) and "Corpus Christi (ponto facultativo)" (Easter plus 60), both + * typed `optional`. Sexta-feira Santa has no statutory basis of its own: Lei 9.093/1995 art. 2º + * places it among the *municipal* religious holidays, and it is typed `national` here because the + * portaria observes it nationwide. The fourth entry, Easter Sunday itself, is emitted as + * `"Páscoa"` typed `religious` and has no normative basis at all: the portaria never mentions it, + * no federal law declares it, and its date is derived arithmetically by `resolveStateHolidayDate` + * with the Meeus/Jones/Butcher algorithm. It is a convenience entry, listed because callers + * computing a liturgical calendar expect it, not because it is a holiday anyone observes as a day + * off. */ export function getHolidays(year: number): Holiday[]; /** diff --git a/src/get-iban-info/get-iban-info.test.ts b/src/get-iban-info/get-iban-info.test.ts new file mode 100644 index 00000000..2d74cd34 --- /dev/null +++ b/src/get-iban-info/get-iban-info.test.ts @@ -0,0 +1,224 @@ +import * as fc from "fast-check"; + +import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; +import { formatIban } from "../format-iban/format-iban"; +import { isValidIban } from "../is-valid-iban/is-valid-iban"; +import { getIbanInfo, type IbanInfo } from "./get-iban-info"; + +const findBrazilianIban = (body: string): string => { + for (let pair = 2; pair <= 98; pair++) { + const candidate = `BR${String(pair).padStart(2, "0")}${body}`; + + if (isValidIban(candidate)) return candidate; + } + + return ""; +}; + +describe("getIbanInfo", () => { + describe("should return the parsed iban", () => { + test("for a known valid IBAN (iban.com Brazil example)", () => { + expect(getIbanInfo("BR1500000000000010932840814P2")).toEqual({ + countryCode: "BR", + checkDigits: "15", + bankIspb: "00000000", + branch: "00001", + account: "0932840814", + accountType: "P", + owner: "2", + }); + }); + + test("for a value with grouping spaces", () => { + expect(getIbanInfo("BR15 0000 0000 0000 1093 2840 814P 2")).toEqual({ + countryCode: "BR", + checkDigits: "15", + bankIspb: "00000000", + branch: "00001", + account: "0932840814", + accountType: "P", + owner: "2", + }); + }); + + test("for a lowercase value", () => { + expect(getIbanInfo("br1500000000000010932840814p2")).toEqual({ + countryCode: "BR", + checkDigits: "15", + bankIspb: "00000000", + branch: "00001", + account: "0932840814", + accountType: "P", + owner: "2", + }); + }); + + test("for a valid IBAN with a corrente (C) account type", () => { + expect(getIbanInfo("BR3860701190000010000012345C1")).toEqual({ + countryCode: "BR", + checkDigits: "38", + bankIspb: "60701190", + branch: "00001", + account: "0000012345", + accountType: "C", + owner: "1", + }); + }); + + test("for a valid IBAN with a poupança (P) account type and a non zero branch", () => { + expect(getIbanInfo("BR1460746948000020001234567P2")).toEqual({ + countryCode: "BR", + checkDigits: "14", + bankIspb: "60746948", + branch: "00002", + account: "0001234567", + accountType: "P", + owner: "2", + }); + }); + + test("for a valid IBAN with an account type letter other than C or P", () => { + expect(getIbanInfo("BR5400000000000010932840814D2")).toEqual({ + countryCode: "BR", + checkDigits: "54", + bankIspb: "00000000", + branch: "00001", + account: "0932840814", + accountType: "D", + owner: "2", + }); + }); + }); + + describe("should return null", () => { + test("when the check digits do not match", () => { + expect(getIbanInfo("BR1500000000000010932840814P3")).toBeNull(); + }); + + test("when the country code is not BR", () => { + expect(getIbanInfo("DE89370400440532013000")).toBeNull(); + }); + + test("when it is shorter than 29 characters", () => { + expect(getIbanInfo("BR15000000000000109328408")).toBeNull(); + }); + + test("when it is longer than 29 characters", () => { + expect(getIbanInfo("BR1500000000000010932840814P2000")).toBeNull(); + }); + + test("when the account type is not a letter", () => { + expect(getIbanInfo("BR150000000000001093284081412")).toBeNull(); + }); + + test("when the owner indicator is 0, which Circular 3.625 art. 2 § 1 does not assign, even though the check digits match", () => { + expect(getIbanInfo("BR6900000000000010932840814P0")).toBeNull(); + }); + + test("when the account type letter does not match the check digits", () => { + expect(getIbanInfo("BR1500000000000010932840814X2")).toBeNull(); + }); + + test("when it carries a character outside the print format", () => { + expect(getIbanInfo("BR1500000000000010932840814P_2")).toBeNull(); + expect(getIbanInfo("BR15,0000,0000,0000,1093,2840,814P2")).toBeNull(); + }); + + test("when a separator falls inside a group instead of at its boundary", () => { + expect(getIbanInfo("BR15 000 00000 0000 1093 2840 814P 2")).toBeNull(); + }); + + test("when it is an empty string", () => { + expect(getIbanInfo("")).toBeNull(); + }); + + test("when it is null", () => { + // @ts-expect-error: intentionally invalid input + expect(getIbanInfo(null)).toBeNull(); + }); + + test("when it is undefined", () => { + // @ts-expect-error: intentionally invalid input + expect(getIbanInfo()).toBeNull(); + }); + + test("when it is a number", () => { + // @ts-expect-error: intentionally invalid input + expect(getIbanInfo(150_000_000_000)).toBeNull(); + }); + }); + + describe("should round-trip with formatIban and isValidIban", () => { + const IBANS = [ + "BR1500000000000010932840814P2", + "BR3860701190000010000012345C1", + "BR1460746948000020001234567P2", + "BR5400000000000010932840814D2", + ]; + + for (const iban of IBANS) { + test(`for ${iban}`, () => { + expect(isValidIban(iban)).toBe(true); + + const parsed = getIbanInfo(iban); + + expect(parsed).not.toBeNull(); + expect( + `BR${parsed?.checkDigits}${parsed?.bankIspb}${parsed?.branch}${parsed?.account}${parsed?.accountType}${parsed?.owner}`, + ).toBe(iban); + expect(formatIban(iban)).toBe(formatIban(iban.toUpperCase())); + }); + } + }); + + describe("properties", () => { + const bodies = fc.stringMatching(/^[0-9]{23}[A-Z][A-Z1-9]$/); + + test("should split an IBAN into fields that spell it back", () => { + fc.assert( + fc.property(bodies, (body) => { + const iban = findBrazilianIban(body); + const parsed = getIbanInfo(formatIban(iban)); + const account = `${parsed?.bankIspb}${parsed?.branch}${parsed?.account}`; + const owner = `${parsed?.accountType}${parsed?.owner}`; + + expect(`${parsed?.countryCode}${parsed?.checkDigits}${account}${owner}`).toBe(iban); + }), + ); + }); + + test("should return a value exactly when the IBAN is valid", () => { + fc.assert( + fc.property(fc.string({ unit: "grapheme" }), (value) => { + expect(getIbanInfo(value) !== null).toBe(isValidIban(value)); + }), + ); + }); + + test("should never throw and always return an IBAN or null", () => { + fc.assert( + fc.property(fc.anything(), (value) => { + const parsed = getIbanInfo(value as string); + + expect(parsed === null || parsed.countryCode === "BR").toBe(true); + }), + ); + }); + }); +}); + +describe("getIbanInfo types", () => { + test("should take a string and return an IbanInfo or null", () => { + expectTypeOf(getIbanInfo).parameter(0).toEqualTypeOf(); + expectTypeOf(getIbanInfo).returns.toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<{ + countryCode: "BR"; + checkDigits: string; + bankIspb: string; + branch: string; + account: string; + accountType: string; + owner: string; + }>(); + }); +}); diff --git a/src/get-iban-info/get-iban-info.ts b/src/get-iban-info/get-iban-info.ts new file mode 100644 index 00000000..f72da77b --- /dev/null +++ b/src/get-iban-info/get-iban-info.ts @@ -0,0 +1,105 @@ +import { sanitizeToAlphanumeric } from "../_internals/sanitize-to-alphanumeric/sanitize-to-alphanumeric"; +import { isValidIban } from "../is-valid-iban/is-valid-iban"; + +/** The fields `getIbanInfo` reads out of a Brazilian IBAN. */ +export type IbanInfo = { + /** ISO 3166-1 alpha-2 country code. Always `"BR"`, the only country this parser supports. */ + countryCode: "BR"; + /** The 2 digit ISO 7064 MOD 97-10 check digits. */ + checkDigits: string; + /** The 8 digit ISPB (Identificador do Sistema de Pagamentos Brasileiro) of the institution. */ + bankIspb: string; + /** The 5 digit branch (agência) number, zero-padded. */ + branch: string; + /** The 10 digit account (conta) number, zero-padded. */ + account: string; + /** + * The 1 letter account type, as published in the "Dicionário de Tipos" of the Catálogo de + * Mensagens e de Arquivos do SFN. `"C"` (conta corrente) and `"P"` (conta poupança) are the + * usual values, but any letter is allowed. + */ + accountType: string; + /** + * The 1 character owner indicator, distinguishing co-owners of the same account: `"1"` for + * the first or only holder up to `"9"` for the ninth, then `"A"` to `"Z"` from the tenth. + */ + owner: string; +}; + +const COUNTRY_CODE_LENGTH = 2; +const CHECK_DIGITS_LENGTH = 2; +const ISPB_LENGTH = 8; +const BRANCH_LENGTH = 5; +const ACCOUNT_LENGTH = 10; +const ACCOUNT_TYPE_LENGTH = 1; + +const COUNTRY_CODE_END = COUNTRY_CODE_LENGTH; +const CHECK_DIGITS_END = COUNTRY_CODE_END + CHECK_DIGITS_LENGTH; +const ISPB_END = CHECK_DIGITS_END + ISPB_LENGTH; +const BRANCH_END = ISPB_END + BRANCH_LENGTH; +const ACCOUNT_END = BRANCH_END + ACCOUNT_LENGTH; +const ACCOUNT_TYPE_END = ACCOUNT_END + ACCOUNT_TYPE_LENGTH; + +/** + * Parses a Brazilian IBAN (International Bank Account Number) into its fields. + * + * The 29 character Brazilian IBAN is laid out as 2 (country code, always `BR`) + 2 (ISO 7064 + * MOD 97-10 check digits) + 8 (ISPB) + 5 (branch) + 10 (account) + 1 (account type, any letter, + * usually `C` for conta corrente or `P` for conta poupança) + 1 (owner indicator, `1` to `9` + * then `A` to `Z`). Only + * Brazilian IBANs are supported: the field layout of the other ISO 13616 countries is out of + * scope, so a well-formed non `BR` IBAN also returns `null`. + * + * Accepts the same input forms as `isValidIban`, compact or in the ISO 13616 print format (groups + * of 4 split by a single whitespace, `.`, `-` or `/`), in either case with optional surrounding + * whitespace and in any case, and returns `null` whenever `isValidIban` would return `false`, + * including a value carrying a separator away from a group boundary, a run of separators or any + * character other than letters and digits. + * + * @param {string} value - The IBAN to be parsed. + * @returns {IbanInfo|null} The parsed IBAN, or `null` when it is not a valid Brazilian IBAN. + * + * @example + * ```typescript + * getIbanInfo("BR1500000000000010932840814P2"); + * // { + * // countryCode: "BR", + * // checkDigits: "15", + * // bankIspb: "00000000", + * // branch: "00001", + * // account: "0932840814", + * // accountType: "P", + * // owner: "2", + * // } + * + * getIbanInfo("BR15 0000 0000 0000 1093 2840 814P 2"); // same result (grouping spaces) + * getIbanInfo("BR15-0000-0000-0000-1093-2840-814P-2"); // same result (any of the mask characters) + * getIbanInfo("DE89370400440532013000"); // null (non Brazilian IBAN) + * getIbanInfo("BR1500000000000010932840814P3"); // null (bad check digits) + * getIbanInfo("BR15 000 00000 0000 1093 2840 814P 2"); // null (a separator inside a group) + * ``` + * + * @see Official: https://www.bcb.gov.br/pre/normativos/circ/2013/pdf/circ_3625_v1_O.pdf + * Circular BCB nº 3.625/2013 + * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/Documents/sistema_pagamentos_brasileiro/IBAN-Guidelines_%20port.pdf + * Diretrizes de Implementação do IBAN no Brasil + * @see Official: https://www.iso.org/standard/81090.html + * ISO 13616-1:2020 (IBAN structure) + * @see Official: https://www.iso.org/standard/31531.html + * ISO/IEC 7064:2003 (MOD 97-10 check digit algorithm) + */ +export const getIbanInfo = (value: string): IbanInfo | null => { + if (!isValidIban(value)) return null; + + const sanitized = sanitizeToAlphanumeric(value); + + return { + countryCode: "BR", + checkDigits: sanitized.slice(COUNTRY_CODE_END, CHECK_DIGITS_END), + bankIspb: sanitized.slice(CHECK_DIGITS_END, ISPB_END), + branch: sanitized.slice(ISPB_END, BRANCH_END), + account: sanitized.slice(BRANCH_END, ACCOUNT_END), + accountType: sanitized.charAt(ACCOUNT_END), + owner: sanitized.slice(ACCOUNT_TYPE_END), + }; +}; diff --git a/src/get-legal-nature/get-legal-nature.test.ts b/src/get-legal-nature/get-legal-nature.test.ts index d68d0213..107c3763 100644 --- a/src/get-legal-nature/get-legal-nature.test.ts +++ b/src/get-legal-nature/get-legal-nature.test.ts @@ -1,47 +1,69 @@ import * as fc from "fast-check"; +import { LEGAL_NATURE_CATEGORIES } from "../_internals/constants/legal-nature-categories"; import { anyValue, digitsUpTo } from "../_internals/test/arbitraries"; import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; import { LEGAL_NATURE } from "../is-valid-legal-nature/constants"; import { isValidLegalNature } from "../is-valid-legal-nature/is-valid-legal-nature"; -import { getLegalNature, type LegalNature } from "./get-legal-nature"; +import { getLegalNature, type LegalNature, type LegalNatureCategory } from "./get-legal-nature"; + +const SOCIEDADE_EMPRESARIA_LIMITADA: LegalNature = { + code: "2062", + description: "Sociedade Empresária Limitada", + category: { code: "2", description: "Entidades Empresariais" }, +}; describe("getLegalNature", () => { it("should reject a code with letters attached, like isValidLegalNature does", () => { expect(getLegalNature("2062a")).toBeNull(); expect(getLegalNature("a2062")).toBeNull(); - expect(getLegalNature("206-2")).toEqual({ - code: "2062", - description: getLegalNature("2062")?.description, - }); + expect(getLegalNature("206-2")).toEqual(SOCIEDADE_EMPRESARIA_LIMITADA); }); it("should return the legal nature entry for a known code as a string", () => { - expect(getLegalNature("2062")).toEqual({ - code: "2062", - description: "Sociedade Empresária Limitada", - }); + expect(getLegalNature("2062")).toEqual(SOCIEDADE_EMPRESARIA_LIMITADA); }); it("should return the legal nature entry for a known code as a number", () => { - expect(getLegalNature(2062)).toEqual({ - code: "2062", - description: "Sociedade Empresária Limitada", - }); + expect(getLegalNature(2062)).toEqual(SOCIEDADE_EMPRESARIA_LIMITADA); }); it("should strip the mask of a number just like the mask of a string", () => { - expect(getLegalNature(206.2)).toEqual({ - code: "2062", - description: "Sociedade Empresária Limitada", - }); + expect(getLegalNature(206.2)).toEqual(SOCIEDADE_EMPRESARIA_LIMITADA); expect(getLegalNature(206.2)).toEqual(getLegalNature("206.2")); }); it("should return the legal nature entry for a masked code (206-2)", () => { - expect(getLegalNature("206-2")).toEqual({ - code: "2062", - description: "Sociedade Empresária Limitada", + expect(getLegalNature("206-2")).toEqual(SOCIEDADE_EMPRESARIA_LIMITADA); + }); + + it("should carry the CONCLA category of the first digit of the code", () => { + expect(getLegalNature("1015")?.category).toEqual({ + code: "1", + description: "Administração Pública", + }); + expect(getLegalNature("3034")?.category).toEqual({ + code: "3", + description: "Entidades sem Fins Lucrativos", + }); + expect(getLegalNature("4014")?.category).toEqual({ + code: "4", + description: "Pessoas Físicas", + }); + expect(getLegalNature("5010")?.category).toEqual({ + code: "5", + description: "Organizações Internacionais e Outras Instituições Extraterritoriais", + }); + }); + + it("should carry the category of the first digit for a legacy code too", () => { + expect(getLegalNature("2208")?.category).toEqual({ + code: "2", + description: "Entidades Empresariais", + }); + expect(getLegalNature("5002")?.category).toEqual({ + code: "5", + description: "Organizações Internacionais e Outras Instituições Extraterritoriais", }); }); @@ -49,6 +71,7 @@ describe("getLegalNature", () => { const first = getLegalNature("2062"); const second = getLegalNature("2062"); expect(first).not.toBe(second); + expect(first?.category).not.toBe(second?.category); }); it("should return null for an unknown 4 digit code", () => { @@ -83,7 +106,11 @@ describe("getLegalNature", () => { test("should look every code of the table up, masked, plain or numeric", () => { fc.assert( fc.property(knownCode, (code) => { - const entry = { code, description: LEGAL_NATURE[code] }; + const entry = { + code, + description: LEGAL_NATURE[code], + category: LEGAL_NATURE_CATEGORIES[code[0]], + }; expect(getLegalNature(code)).toEqual(entry); expect(getLegalNature(`${code.slice(0, 3)}-${code.slice(3)}`)).toEqual(entry); @@ -122,4 +149,10 @@ describe("getLegalNature types", () => { expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); }); + + test("should type the category as a code of the five CONCLA groups and a description", () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<"1" | "2" | "3" | "4" | "5">(); + expectTypeOf().toEqualTypeOf(); + }); }); diff --git a/src/get-legal-nature/get-legal-nature.ts b/src/get-legal-nature/get-legal-nature.ts index 311cc297..d008998e 100644 --- a/src/get-legal-nature/get-legal-nature.ts +++ b/src/get-legal-nature/get-legal-nature.ts @@ -1,5 +1,11 @@ +import { + LEGAL_NATURE_CATEGORIES, + type LegalNatureCategory, +} from "../_internals/constants/legal-nature-categories"; import { LEGAL_NATURE, MASK_REGEX } from "../is-valid-legal-nature/constants"; +export type { LegalNatureCategory } from "../_internals/constants/legal-nature-categories"; + /** * A Brazilian legal nature (natureza jurídica) entry. */ @@ -8,12 +14,18 @@ export type LegalNature = { code: string; /** The official description in Portuguese, per IBGE/CONCLA. */ description: string; + /** The CONCLA category the code belongs to, given by its first digit. */ + category: LegalNatureCategory; }; const lookUp = (code: string): LegalNature | null => { if (!Object.hasOwn(LEGAL_NATURE, code)) return null; - return { code, description: LEGAL_NATURE[code] }; + return { + code, + description: LEGAL_NATURE[code], + category: { ...LEGAL_NATURE_CATEGORIES[code[0]] }, + }; }; /** @@ -22,18 +34,36 @@ const lookUp = (code: string): LegalNature | null => { * The usual mask characters (hyphens, dots, whitespace) are stripped before the lookup, from a * number as well as from a string, so `getLegalNature(206.2)` resolves like `getLegalNature("206.2")`. * + * No legal nature code starts with a zero, its first digit is the CONCLA category (1 to 5), so + * nothing is ever padded here: a number and the string of the same digits are read identically, + * and a value narrower than 4 digits is not a code at all. + * + * The entry also carries the CONCLA category of the code, the group the table lists it under, + * taken from its first digit: 1 Administração Pública, 2 Entidades Empresariais, 3 Entidades + * sem Fins Lucrativos, 4 Pessoas Físicas and 5 Organizações Internacionais e Outras + * Instituições Extraterritoriais. + * * @param {string|number} value - The legal nature code to look up, with or without formatting. * @returns {LegalNature|null} The matching legal nature entry, or null when the code is unknown * or invalid. * + * The CONCLA table page sits behind a bot filter and answers HTTP 403 to every non-browser + * client, so it has to be opened in a browser; the detailed structure PDF next to it is served + * normally. + * * @see Official: https://concla.ibge.gov.br/estrutura/natjur-estrutura/natureza-juridica-2021 * @see Official: https://concla.ibge.gov.br/images/concla/documentacao/CONCLA-TNJ2021-EstruturaDetalhada.pdf * * @example * ```typescript - * getLegalNature("2062"); // { code: "2062", description: "Sociedade Empresária Limitada" } - * getLegalNature("206-2"); // { code: "2062", description: "Sociedade Empresária Limitada" } - * getLegalNature(206.2); // { code: "2062", description: "Sociedade Empresária Limitada" } + * getLegalNature("2062"); + * // { + * // code: "2062", + * // description: "Sociedade Empresária Limitada", + * // category: { code: "2", description: "Entidades Empresariais" }, + * // } + * getLegalNature("206-2")?.code; // "2062" + * getLegalNature(206.2)?.category.description; // "Entidades Empresariais" * getLegalNature("0000"); // null * ``` */ diff --git a/src/get-legal-natures-by-category/get-legal-natures-by-category.test.ts b/src/get-legal-natures-by-category/get-legal-natures-by-category.test.ts new file mode 100644 index 00000000..e82b1da6 --- /dev/null +++ b/src/get-legal-natures-by-category/get-legal-natures-by-category.test.ts @@ -0,0 +1,292 @@ +import * as fc from "fast-check"; + +import { LEGAL_NATURE_CATEGORIES } from "../_internals/constants/legal-nature-categories"; +import { anyValue } from "../_internals/test/arbitraries"; +import { expectNeverThrows } from "../_internals/test/properties"; +import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; +import { getLegalNature, type LegalNature } from "../get-legal-nature/get-legal-nature"; +import { LEGAL_NATURE } from "../is-valid-legal-nature/constants"; +import { getLegalNaturesByCategory } from "./get-legal-natures-by-category"; + +const PESSOAS_FISICAS: LegalNature[] = [ + { + code: "4014", + description: "Empresa Individual Imobiliária", + category: { code: "4", description: "Pessoas Físicas" }, + }, + { + code: "4022", + description: "Segurado Especial", + category: { code: "4", description: "Pessoas Físicas" }, + }, + { + code: "4081", + description: "Contribuinte individual", + category: { code: "4", description: "Pessoas Físicas" }, + }, + { + code: "4090", + description: "Candidato a Cargo Político Eletivo", + category: { code: "4", description: "Pessoas Físicas" }, + }, + { + code: "4111", + description: "Leiloeiro", + category: { code: "4", description: "Pessoas Físicas" }, + }, + { + code: "4120", + description: "Produtor Rural (Pessoa Física)", + category: { code: "4", description: "Pessoas Físicas" }, + }, +]; + +const EXTRATERRITORIAIS: LegalNature[] = [ + { + code: "5002", + description: "Organização Internacional e Outras Instituições Extraterritoriais", + category: { + code: "5", + description: "Organizações Internacionais e Outras Instituições Extraterritoriais", + }, + }, + { + code: "5010", + description: "Organização Internacional", + category: { + code: "5", + description: "Organizações Internacionais e Outras Instituições Extraterritoriais", + }, + }, + { + code: "5029", + description: "Representação Diplomática Estrangeira", + category: { + code: "5", + description: "Organizações Internacionais e Outras Instituições Extraterritoriais", + }, + }, + { + code: "5037", + description: "Outras Instituições Extraterritoriais", + category: { + code: "5", + description: "Organizações Internacionais e Outras Instituições Extraterritoriais", + }, + }, +]; + +const codesOf = (category: string | number): string[] => + getLegalNaturesByCategory(category).map((legalNature) => legalNature.code); + +describe("getLegalNaturesByCategory", () => { + test("should return the whole category as entries, ascending by code", () => { + expect(getLegalNaturesByCategory("4")).toEqual(PESSOAS_FISICAS); + }); + + test("should list the legacy codes of the category too, in code order", () => { + expect(getLegalNaturesByCategory("5")).toEqual(EXTRATERRITORIAIS); + }); + + test("should accept the category code as a number", () => { + expect(getLegalNaturesByCategory(4)).toEqual(PESSOAS_FISICAS); + expect(getLegalNaturesByCategory(5)).toEqual(EXTRATERRITORIAIS); + }); + + test("should list the 32 codes of Administração Pública", () => { + expect(codesOf("1")).toEqual([ + "1015", + "1023", + "1031", + "1040", + "1058", + "1066", + "1074", + "1082", + "1104", + "1112", + "1120", + "1139", + "1147", + "1155", + "1163", + "1171", + "1180", + "1198", + "1210", + "1228", + "1236", + "1244", + "1252", + "1260", + "1279", + "1287", + "1295", + "1309", + "1317", + "1325", + "1333", + "1341", + ]); + }); + + test("should list the 33 codes of Entidades Empresariais, legacy ones in place", () => { + expect(codesOf("2")).toEqual([ + "2011", + "2038", + "2046", + "2054", + "2062", + "2070", + "2076", + "2089", + "2097", + "2100", + "2127", + "2135", + "2143", + "2151", + "2160", + "2178", + "2194", + "2208", + "2216", + "2224", + "2232", + "2240", + "2259", + "2267", + "2275", + "2283", + "2291", + "2305", + "2313", + "2321", + "2330", + "2348", + "2356", + ]); + }); + + test("should list the 25 codes of Entidades sem Fins Lucrativos", () => { + expect(codesOf("3")).toEqual([ + "3034", + "3042", + "3050", + "3069", + "3077", + "3085", + "3093", + "3107", + "3115", + "3123", + "3131", + "3204", + "3212", + "3220", + "3239", + "3247", + "3255", + "3263", + "3271", + "3280", + "3298", + "3301", + "3310", + "3328", + "3999", + ]); + }); + + test("should return a fresh array of fresh entries on every call", () => { + const first = getLegalNaturesByCategory("5"); + const second = getLegalNaturesByCategory("5"); + + expect(first).not.toBe(second); + expect(first[0]).not.toBe(second[0]); + expect(first[0].category).not.toBe(second[0].category); + }); + + test("should return an empty array for a category outside 1 to 5", () => { + expect(getLegalNaturesByCategory("0")).toEqual([]); + expect(getLegalNaturesByCategory("6")).toEqual([]); + expect(getLegalNaturesByCategory("9")).toEqual([]); + expect(getLegalNaturesByCategory(9)).toEqual([]); + }); + + test("should return an empty array for a prefix that is not a category on its own", () => { + expect(getLegalNaturesByCategory("20")).toEqual([]); + expect(getLegalNaturesByCategory("2062")).toEqual([]); + expect(getLegalNaturesByCategory(20)).toEqual([]); + }); + + test("should return an empty array for an empty or padded category code", () => { + expect(getLegalNaturesByCategory("")).toEqual([]); + expect(getLegalNaturesByCategory(" 2")).toEqual([]); + expect(getLegalNaturesByCategory("02")).toEqual([]); + }); + + test("should return an empty array for a value that is not a string or a number", () => { + // @ts-expect-error not a string or number + expect(getLegalNaturesByCategory(null)).toEqual([]); + // @ts-expect-error not a string or number + expect(getLegalNaturesByCategory()).toEqual([]); + // @ts-expect-error not a string or number + expect(getLegalNaturesByCategory(["2"])).toEqual([]); + expect(getLegalNaturesByCategory(Object.create(null))).toEqual([]); + }); + + test("should partition the whole table across the five categories", () => { + const codes = Object.keys(LEGAL_NATURE_CATEGORIES).flatMap((category) => codesOf(category)); + + expect(codes.length).toBe(100); + expect(new Set(codes).size).toBe(100); + expect(codes.every((code) => Object.hasOwn(LEGAL_NATURE, code))).toBe(true); + }); + + describe("properties", () => { + const categoryCodes = fc.constantFrom(...Object.keys(LEGAL_NATURE_CATEGORIES)); + + test("should return the same entry getLegalNature returns for every code it lists", () => { + fc.assert( + fc.property(categoryCodes, (category) => { + for (const legalNature of getLegalNaturesByCategory(category)) { + expect(legalNature).toEqual(getLegalNature(legalNature.code)); + expect(legalNature.code.startsWith(category)).toBe(true); + } + }), + ); + }); + + test("should return the codes of a category in ascending order", () => { + fc.assert( + fc.property(categoryCodes, (category) => { + const codes = codesOf(category); + + expect(codes).toEqual([...codes].sort((a, b) => Number(a) - Number(b))); + }), + ); + }); + + test("should read a number category exactly like its string form", () => { + fc.assert( + fc.property(categoryCodes, (category) => { + expect(getLegalNaturesByCategory(Number(category))).toEqual( + getLegalNaturesByCategory(category), + ); + }), + ); + }); + + test("should never throw, whatever it is given", () => { + expectNeverThrows(getLegalNaturesByCategory, anyValue); + }); + }); +}); + +describe("getLegalNaturesByCategory types", () => { + test("should take a string or number category and return an array of legal natures", () => { + expectTypeOf(getLegalNaturesByCategory).parameter(0).toEqualTypeOf(); + expectTypeOf(getLegalNaturesByCategory).returns.toEqualTypeOf(); + }); +}); diff --git a/src/get-legal-natures-by-category/get-legal-natures-by-category.ts b/src/get-legal-natures-by-category/get-legal-natures-by-category.ts new file mode 100644 index 00000000..f4209cf8 --- /dev/null +++ b/src/get-legal-natures-by-category/get-legal-natures-by-category.ts @@ -0,0 +1,59 @@ +import { LEGAL_NATURE_CATEGORIES } from "../_internals/constants/legal-nature-categories"; +import { type LegalNature } from "../get-legal-nature/get-legal-nature"; +import { LEGAL_NATURE } from "../is-valid-legal-nature/constants"; + +/** + * Retrieves every Brazilian legal nature (natureza jurídica) of a CONCLA category. + * + * The category is the first digit of the four digit code, the heading the table lists the code + * under: 1 Administração Pública, 2 Entidades Empresariais, 3 Entidades sem Fins Lucrativos, + * 4 Pessoas Físicas and 5 Organizações Internacionais e Outras Instituições Extraterritoriais. + * It is accepted as a string or as a number, so `"2"` and `2` return the same list. + * + * The legacy codes `LEGAL_NATURE` keeps for 2.3.0 compatibility (2076, 2100, 2208, 3042, 3050, + * 3093, 3123 and 5002) are listed under the category of their first digit as well. The result + * is in ascending code order, since the table is keyed by the codes themselves, and is a fresh + * array of fresh entries on every call. + * + * @param {string|number} category - The category code, `"1"` through `"5"` or 1 through 5. + * @returns {LegalNature[]} The legal natures of the category, sorted by code, or an empty array + * when the category is unknown or the input is invalid. + * + * The CONCLA table page sits behind a bot filter and answers HTTP 403 to every non-browser + * client, so it has to be opened in a browser; the detailed structure PDF next to it is served + * normally. + * + * @see Official: https://concla.ibge.gov.br/estrutura/natjur-estrutura/natureza-juridica-2021 + * @see Official: https://concla.ibge.gov.br/images/concla/documentacao/CONCLA-TNJ2021-EstruturaDetalhada.pdf + * + * @example + * ```typescript + * getLegalNaturesByCategory("4")[0]; + * // { + * // code: "4014", + * // description: "Empresa Individual Imobiliária", + * // category: { code: "4", description: "Pessoas Físicas" }, + * // } + * getLegalNaturesByCategory(4).length; // 6 + * getLegalNaturesByCategory("2").length; // 33 + * getLegalNaturesByCategory("9"); // [] + * ``` + */ +export const getLegalNaturesByCategory = (category: string | number): LegalNature[] => { + if (typeof category !== "string" && typeof category !== "number") return []; + + const categoryCode = String(category); + + if (!Object.hasOwn(LEGAL_NATURE_CATEGORIES, categoryCode)) return []; + + const entry = LEGAL_NATURE_CATEGORIES[categoryCode]; + const legalNatures: LegalNature[] = []; + + for (const [code, description] of Object.entries(LEGAL_NATURE)) { + if (code.startsWith(categoryCode)) { + legalNatures.push({ code, description, category: { ...entry } }); + } + } + + return legalNatures; +}; diff --git a/src/get-legal-natures/get-legal-natures.test.ts b/src/get-legal-natures/get-legal-natures.test.ts index 7cf617ac..76db888e 100644 --- a/src/get-legal-natures/get-legal-natures.test.ts +++ b/src/get-legal-natures/get-legal-natures.test.ts @@ -35,7 +35,7 @@ describe("getLegalNatures", () => { expect(code).toMatch(/^\d{4}$/); expect(isValidLegalNature(code)).toBe(true); - expect(getLegalNature(code)).toEqual(entry); + expect(getLegalNature(code)).toMatchObject(entry); }), ); }); diff --git a/src/get-legal-natures/get-legal-natures.ts b/src/get-legal-natures/get-legal-natures.ts index a98b3500..adfc3d83 100644 --- a/src/get-legal-natures/get-legal-natures.ts +++ b/src/get-legal-natures/get-legal-natures.ts @@ -10,6 +10,10 @@ import { LEGAL_NATURE } from "../is-valid-legal-nature/constants"; * getLegalNatures()["2062"]; // "Sociedade Empresária Limitada" * ``` * + * The CONCLA table page sits behind a bot filter and answers HTTP 403 to every non-browser + * client, so it has to be opened in a browser; the detailed structure PDF next to it is served + * normally. + * * @see Official: https://concla.ibge.gov.br/estrutura/natjur-estrutura/natureza-juridica-2021 * @see Official: https://concla.ibge.gov.br/images/concla/documentacao/CONCLA-TNJ2021-EstruturaDetalhada.pdf */ diff --git a/src/get-municipalities/get-municipalities.test.ts b/src/get-municipalities/get-municipalities.test.ts index 18f7af21..dd69fd99 100644 --- a/src/get-municipalities/get-municipalities.test.ts +++ b/src/get-municipalities/get-municipalities.test.ts @@ -30,6 +30,15 @@ describe("getMunicipalities", () => { expect(names).toEqual(sortedNames); }); + it("should sort every per-state list with the pt-BR comparator", () => { + for (const state of getStates()) { + const names = getMunicipalities(state.code).map((municipality) => municipality.name); + const sortedNames = [...names].sort((a, b) => a.localeCompare(b, "pt-BR")); + + expect(names).toEqual(sortedNames); + } + }); + it("should return municipality objects shaped as { code, name, stateCode }", () => { const saoPaulo = getMunicipalities("SP").find( (municipality) => municipality.name === "São Paulo", diff --git a/src/get-municipalities/get-municipalities.ts b/src/get-municipalities/get-municipalities.ts index 82132108..a5909b8f 100644 --- a/src/get-municipalities/get-municipalities.ts +++ b/src/get-municipalities/get-municipalities.ts @@ -2,6 +2,9 @@ import { DATA as CITIES_DATA, type Municipality } from "../_internals/constants/ import { type StateCode } from "../_internals/constants/states"; import { getStates } from "../get-states/get-states"; +export type { Municipality } from "../_internals/constants/cities"; +export type { StateCode } from "../_internals/constants/states"; + const buildMunicipalities = (stateCode: StateCode): Municipality[] => CITIES_DATA[stateCode].map(([name, code]) => ({ code, name, stateCode })); @@ -10,7 +13,18 @@ const buildMunicipalities = (stateCode: StateCode): Municipality[] => * * If `stateCode` is provided, only municipalities of that state are returned. If it is * omitted, every municipality of every state is returned, sorted with `localeCompare` in the - * "pt-BR" locale so accented names land where a Brazilian reader expects them. + * "pt-BR" locale so accented names land where a Brazilian reader expects them. Every per-state + * list is sorted the same way. + * + * Only an omitted (or `undefined`) `stateCode` asks for the full list: any other value that is + * not a known state code, `null` and `""` included, returns `[]`. The sibling `getCities` is + * looser and treats every falsy `state` as "no state given", so `getCities(null)` returns the + * full list where `getMunicipalities(null)` returns `[]`. + * + * The state code is matched exactly, case included: `getMunicipalities("sp")` returns `[]` where + * `getMunicipalities("SP")` returns the 645 São Paulo municipalities. `getMunicipalities` and + * `getCities` are the only state-taking lookups that are case-sensitive; `getStateNameByCode`, + * `getTimezoneByState`, `getAreaCodesByState` and `getMunicipality` all fold case. * * @param {StateCode} [stateCode] - The two letter code of the Brazilian state to filter by. * @returns {Municipality[]} A fresh array of fresh `Municipality` objects. Empty when @@ -21,6 +35,8 @@ const buildMunicipalities = (stateCode: StateCode): Municipality[] => * getMunicipalities("SP")[0]; // { code: "3500105", name: "Adamantina", stateCode: "SP" } * getMunicipalities().length; // every municipality of every state * getMunicipalities("ZZ"); // [] + * getMunicipalities("sp"); // [] (the state code is case-sensitive here) + * getMunicipalities(null); // [] (only an omitted state code asks for the full list) * ``` * * @see Official: https://servicodados.ibge.gov.br/api/docs/localidades diff --git a/src/get-municipality-by-code/get-municipality-by-code.ts b/src/get-municipality-by-code/get-municipality-by-code.ts index 0fe0a02f..c4a2c216 100644 --- a/src/get-municipality-by-code/get-municipality-by-code.ts +++ b/src/get-municipality-by-code/get-municipality-by-code.ts @@ -3,6 +3,8 @@ import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; import { getStates } from "../get-states/get-states"; +export type { Municipality } from "../_internals/constants/cities"; + /** * Looks up a Brazilian municipality by its 7 digit IBGE code, published by the IBGE. * diff --git a/src/get-municipality/get-municipality.test.ts b/src/get-municipality/get-municipality.test.ts index 6f969b19..6703bdbe 100644 --- a/src/get-municipality/get-municipality.test.ts +++ b/src/get-municipality/get-municipality.test.ts @@ -39,17 +39,47 @@ describe("getMunicipality", () => { ); }); + it("should collapse every run of internal whitespace in the municipality name before matching", async () => { + await expect(getMunicipality({ municipalityName: "sao paulo", uf: "sp" })).resolves.toBe( + "3550308", + ); + await expect(getMunicipality({ municipalityName: "sao\tpaulo", uf: "sp" })).resolves.toBe( + "3550308", + ); + await expect(getMunicipality({ municipalityName: "sao\npaulo", uf: "sp" })).resolves.toBe( + "3550308", + ); + await expect( + getMunicipality({ municipalityName: " Angra \t dos \n Reis ", uf: "RJ" }), + ).resolves.toBe("3300100"); + }); + + it("should not match a municipality name written without the space the dataset carries", async () => { + await expect(getMunicipality({ municipalityName: "saopaulo", uf: "SP" })).resolves.toBeNull(); + }); + + it("should fold the casing to upper case, the direction that expands ß to SS", async () => { + await expect(getMunicipality({ municipalityName: "Passos", uf: "MG" })).resolves.toBe( + "3147907", + ); + await expect(getMunicipality({ municipalityName: "Paßos", uf: "MG" })).resolves.toBe("3147907"); + }); + it("should trim and uppercase a uf with surrounding whitespace and lowercase letters", async () => { await expect(getMunicipality({ municipalityName: "São Paulo", uf: " sp " })).resolves.toBe( "3550308", ); }); - it("should return the same cached tuple instance across repeated calls with the same code", async () => { + it("should return a fresh pair, so mutating it leaves a later lookup of the same code intact", async () => { const first = await getMunicipality({ code: "3550308" }); - const second = await getMunicipality({ code: "3550308" }); - expect(first).toBe(second); + expect(first).toStrictEqual(["São Paulo", "SP"]); + + first?.fill("Mutated"); + + expect(first).toStrictEqual(["Mutated", "Mutated"]); + await expect(getMunicipality({ code: "3550308" })).resolves.toStrictEqual(["São Paulo", "SP"]); }); it("should resolve a known Boa Esperança do Norte/MT lookup", async () => { @@ -187,9 +217,10 @@ describe("getMunicipality", () => { }); }); +const lookUpEither = (options: GetMunicipalityOptions) => getMunicipality(options); + describe("getMunicipality types", () => { - it("should take a code or a name plus uf and resolve to a pair, a name or null", () => { - expectTypeOf(getMunicipality).parameter(0).toEqualTypeOf(); + it("should take a code or a name plus uf", () => { expectTypeOf().toEqualTypeOf< GetMunicipalityByCodeOptions | GetMunicipalityByNameOptions >(); @@ -198,8 +229,13 @@ describe("getMunicipality types", () => { municipalityName: string; uf: string; }>(); - expectTypeOf(getMunicipality).returns.resolves.toEqualTypeOf< - [string, string] | string | null - >(); + }); + + it("should overload the return type on the direction of the lookup", () => { + const byCode: GetMunicipalityByCodeOptions = { code: "3550308" }; + const byName: GetMunicipalityByNameOptions = { municipalityName: "São Paulo", uf: "SP" }; + expectTypeOf(getMunicipality(byCode)).resolves.toEqualTypeOf<[string, string] | null>(); + expectTypeOf(getMunicipality(byName)).resolves.toEqualTypeOf(); + expectTypeOf(lookUpEither).returns.resolves.toEqualTypeOf<[string, string] | string | null>(); }); }); diff --git a/src/get-municipality/get-municipality.ts b/src/get-municipality/get-municipality.ts index ff56f74c..743f2211 100644 --- a/src/get-municipality/get-municipality.ts +++ b/src/get-municipality/get-municipality.ts @@ -1,8 +1,8 @@ import { DATA as CITIES_DATA } from "../_internals/constants/cities"; import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; import { isNullish } from "../_internals/is-nullish/is-nullish"; +import { normalizeMunicipalityName } from "../_internals/normalize-municipality-name/normalize-municipality-name"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; -import { removeAccents } from "../remove-accents/remove-accents"; /** The `getMunicipality` query by IBGE municipality code. */ export type GetMunicipalityByCodeOptions = { @@ -23,15 +23,10 @@ export type GetMunicipalityOptions = GetMunicipalityByCodeOptions | GetMunicipal let codeIndex: Map | undefined; -// Stryker disable next-line MethodExpression: normalizeName is only ever used to compare two -// values against each other (never returned or displayed), and every name in the dataset is -// plain ASCII Latin letters once accents are stripped, so folding to upper or lower case is -// symmetric and cannot change which names are considered equal. -const normalizeName = (value: string): string => removeAccents(value).trim().toUpperCase(); - const getMunicipalityByCode = (code: string | number): [string, string] | null => { if (!isLookupCode(code)) return null; + // Stryker disable next-line ConditionalExpression: this guard only memoizes; CITIES_DATA is a module level constant that is never written to, so rebuilding the index on every call produces the very same entries, and each lookup already returns a fresh copy of the pair, leaving the repeated work unobservable. if (!codeIndex) { codeIndex = new Map(); @@ -45,7 +40,9 @@ const getMunicipalityByCode = (code: string | number): [string, string] | null = // `Map#get` never throws and simply misses for a key of the wrong shape (a malformed, too // short or too long code), so only the sign and the decimal point of a numeric `code`, which // `sanitizeToDigits` would silently drop, have to be pre-validated above. - return codeIndex.get(sanitizeToDigits(code)) ?? null; + const entry = codeIndex.get(sanitizeToDigits(code)); + + return entry ? [...entry] : null; }; const getMunicipalityCodeByName = ({ @@ -62,23 +59,74 @@ const getMunicipalityCodeByName = ({ if (!stateEntry) return null; - // `removeAccents` (and so `normalizeName`) already folds a non-string or empty + // `removeAccents` (and so `normalizeMunicipalityName`) already folds a non-string or empty // `municipalityName` down to `""`, which no real municipality name normalizes to, so there is // no need to pre-validate `municipalityName` here first. - const normalizedName = normalizeName(municipalityName); - const match = stateEntry[1].find(([name]) => normalizeName(name) === normalizedName); + const normalizedName = normalizeMunicipalityName(municipalityName); + const match = stateEntry[1].find(([name]) => normalizeMunicipalityName(name) === normalizedName); return match ? match[1] : null; }; /** - * Looks a Brazilian municipality up in the offline IBGE "localidades" dataset. + * Looks a Brazilian municipality up by its IBGE code in the offline IBGE "localidades" dataset. + * + * A `code` given as a number must be a non-negative integer: a sign and a decimal point are not + * digits, so `-3550308` and `355030.8` are rejected instead of being read as `3550308`. + * + * @deprecated Use `getMunicipalityByCode` instead, which is synchronous and offline; matching a + * municipality by name is up to the application, over `getMunicipalities`. + * + * @param {GetMunicipalityByCodeOptions} options - The `{ code }` query. + * @returns {Promise<[string, string] | null>} A fresh `[name, uf]` pair, which the caller owns + * and may mutate, or null when the code is malformed or unknown. * - * Given a `code` it resolves the municipality name and its UF; given a `municipalityName` - * and a `uf` it resolves the IBGE code. The name lookup ignores accents and casing. - * Validation failures and unknown municipalities are reported as `null`. A `code` given as a - * number must be a non-negative integer: a sign and a decimal point are not digits, so - * `-3550308` and `355030.8` are rejected instead of being read as `3550308`. + * @example + * ```typescript + * await getMunicipality({ code: "3550308" }); // ["São Paulo", "SP"] + * await getMunicipality({ code: 3550308 }); // ["São Paulo", "SP"] + * ``` + * + * @see Official: https://servicodados.ibge.gov.br/api/docs/localidades + */ +export function getMunicipality( + options: GetMunicipalityByCodeOptions, +): Promise<[string, string] | null>; + +/** + * Looks a Brazilian municipality's IBGE code up in the offline IBGE "localidades" dataset. + * + * The name lookup ignores accents and casing, and every run of whitespace collapses into a + * single space, so `"sao paulo"` matches `"São Paulo"`; a name written without the space does + * not, since only the runs that are there collapse. The casing is folded to upper case, the + * direction Unicode expands `"ß"` to `"SS"` in, so `"Paßos"` matches `"Passos"`. + * + * @deprecated Use `getMunicipalityByCode` instead, which is synchronous and offline; matching a + * municipality by name is up to the application, over `getMunicipalities`. + * + * @param {GetMunicipalityByNameOptions} options - The `{ municipalityName, uf }` query. + * @returns {Promise} The 7 digit IBGE code, or null when the state code or the + * municipality is unknown. + * + * @example + * ```typescript + * await getMunicipality({ municipalityName: "sao paulo", uf: "sp" }); // "3550308" + * ``` + * + * @see Official: https://servicodados.ibge.gov.br/api/docs/localidades + */ +export function getMunicipality(options: GetMunicipalityByNameOptions): Promise; + +/** + * Looks a Brazilian municipality up in the offline IBGE "localidades" dataset, from a query + * whose direction is only known at run time. + * + * Given a `code` it resolves the municipality name and its UF; given a `municipalityName` and a + * `uf` it resolves the IBGE code. Validation failures and unknown municipalities are reported + * as `null`. + * + * @deprecated Use `getMunicipalityByCode` instead, which is synchronous and offline; matching a + * municipality by name is up to the application, over `getMunicipalities`. * * @param {GetMunicipalityOptions} options - Either `{ code }` or `{ municipalityName, uf }`. * @returns {Promise<[string, string] | string | null>} The `[name, uf]` pair when looking up @@ -88,16 +136,20 @@ const getMunicipalityCodeByName = ({ * * @example * ```typescript - * await getMunicipality({ code: "3550308" }); // ["São Paulo", "SP"] - * await getMunicipality({ code: 3550308 }); // ["São Paulo", "SP"] - * await getMunicipality({ municipalityName: "sao paulo", uf: "sp" }); // "3550308" + * const lookUp = (options: GetMunicipalityOptions) => getMunicipality(options); + * + * await lookUp({ code: "3550308" }); // ["São Paulo", "SP"] * ``` * * @see Official: https://servicodados.ibge.gov.br/api/docs/localidades */ -export const getMunicipality = ( +export function getMunicipality( + options: GetMunicipalityOptions, +): Promise<[string, string] | string | null>; + +export function getMunicipality( options: GetMunicipalityOptions, -): Promise<[string, string] | null | string> => { +): Promise<[string, string] | string | null> { if (isNullish(options) || typeof options !== "object" || Array.isArray(options)) { return Promise.resolve(null); } @@ -107,4 +159,4 @@ export const getMunicipality = ( } return Promise.resolve(getMunicipalityCodeByName(options)); -}; +} diff --git a/src/parse-nfe-key/constants.ts b/src/get-nfe-key-info/constants.ts similarity index 63% rename from src/parse-nfe-key/constants.ts rename to src/get-nfe-key-info/constants.ts index 95a3726d..df4ece91 100644 --- a/src/parse-nfe-key/constants.ts +++ b/src/get-nfe-key-info/constants.ts @@ -1,5 +1,5 @@ /** - * The `mod` (modelo do documento) values `parseNfeKey` supports, every one of them a document + * The `mod` (modelo do documento) values `getNfeKeyInfo` supports, every one of them a document * whose "chave de acesso" is the same 44 digit string built the same way: 55 NF-e, 57 CT-e, * 58 MDF-e, 62 NFCom, 63 BP-e, 64 GTV-e (the CT-e Guia de Transporte de Valores), 65 NFC-e, * 66 NF3e and 67 CT-e OS (Conhecimento de Transporte Eletrônico para Outros Serviços). @@ -10,16 +10,17 @@ */ export const VALID_MODELS = ["55", "57", "58", "62", "63", "64", "65", "66", "67"] as const; -/** One of the `mod` values `parseNfeKey` supports. */ +/** One of the `mod` values `getNfeKeyInfo` supports. */ export type ValidModel = (typeof VALID_MODELS)[number]; /** * The `tpEmis` (forma de emissão) codes each MOC assigns to its own document, so a code that is * meaningful for one document does not make a key of another valid. * - * NF-e and NFC-e (MOC 7.0 Anexo I, field B22): 1 normal, 2 contingência FS-IA, 3 contingência - * SCAN, 4 contingência EPEC, 5 contingência FS-DA, 6 contingência SVC-AN, 7 contingência SVC-RS - * and 9 contingência off-line da NFC-e. + * NF-e and NFC-e (MOC 7.0 Anexo I, field B22): 1 normal, 2 contingência FS-IA, 3 Regime Especial + * NFF, 4 contingência EPEC, 5 contingência FS-DA, 6 contingência SVC-AN, 7 contingência SVC-RS + * and 9 contingência off-line da NFC-e. Code 3 used to be "contingência SCAN"; NT 2021.002 + * redefined it as the Regime Especial da Nota Fiscal Fácil, leaving the value set unchanged. * * CT-e (CT-e MOC 4.00 Anexo I, field D19): 1 normal, 3 Regime Especial NFF, 4 EPEC pela SVC, * 5 contingência FS-DA, 7 autorização pela SVC-RS and 8 autorização pela SVC-SP. CT-e OS @@ -27,7 +28,7 @@ export type ValidModel = (typeof VALID_MODELS)[number]; * 8. Rule G011 of the same annex, "(7=SVC-RS e 8=SVC-SP)", is what makes 8 a real code here, * even though the NF-e MOC never assigns it. * - * MDF-e (MDF-e MOC 3.00 Anexo I, domain D7): 1 normal, 2 contingência off-line and 3 Regime + * MDF-e (MDF-e MOC 3.00b Anexo I, domain D7): 1 normal, 2 contingência off-line and 3 Regime * Especial NFF. NFCom, BP-e and NF3e (their own Anexo I, domain D7): 1 normal and * 2 contingência off-line. */ @@ -79,18 +80,23 @@ export const FORBIDDEN_CODES: readonly string[] = [ "01234567", ]; -/** The models rule B03-10 is written for, the only ones whose `cNF` it constrains. */ +/** + * The models rule B03-10 is written for, the only ones whose `cNF` it constrains. + * + * The scope is stated inconsistently by the sources: the change log of NT 2019.001 v1.40 says + * modelo 65 was taken out of the rule, while MOC 7.0 Anexo I still prints its applicability as + * `55/65`. The MOC being the consolidated text in force, both models are kept here. + */ export const FORBIDDEN_CODE_MODELS: readonly string[] = ["55", "65"]; /** - * The prefixes the `Id` attribute of a DF-e XML puts in front of the 44 digits, one per - * document: `NFe`, `CTe`, `MDFe`, `BPe`, `NF3e` and `NFCom`. Stripped before the digits are - * read, since `NF3e` carries a digit of its own. + * Shape the key has to be written in once the prefix is stripped: the digits, optionally split + * into the printed groups of 4 by whitespace or the usual mask characters, a run of them between + * two groups included, the same rule the CPF, CNPJ, CAEPF and CNS regexes of this library follow. + * A separator inside a group of 4, or any other character, is rejected instead of being stripped. + * The group count is left open so the 44 digit length is still checked where the key is read. */ -export const XML_ID_PREFIX_REGEX = /^(?:nfe|cte|mdfe|bpe|nf3e|nfcom)/i; - -/** Digits and optional whitespace between groups, what is left once the prefix is stripped. */ -export const FORMAT_REGEX = /^[\d\s]+$/; +export const FORMAT_REGEX = /^\d{4}(?:[\s.\-/]*\d{4})*$/; /** Start of the document number (nNF) inside the 44 digit key. */ export const NUMBER_START = 25; @@ -98,5 +104,9 @@ export const NUMBER_START = 25; /** End (exclusive) of the document number (nNF) inside the 44 digit key. */ export const NUMBER_END = 34; -/** A document number of all zeros is not a valid nNF. */ +/** + * A document number of all zeros is not a valid nNF: the leiaute types `nNF` as `TNF`, whose + * pattern is `[1-9]{1}[0-9]{0,8}` in `tiposBasico_v4.00.xsd`, and the Anexo I of every other + * model repeats the same regex for its own number field. + */ export const ABSENT_NUMBER = "000000000"; diff --git a/src/get-nfe-key-info/get-nfe-key-info.test.ts b/src/get-nfe-key-info/get-nfe-key-info.test.ts new file mode 100644 index 00000000..9c385dfc --- /dev/null +++ b/src/get-nfe-key-info/get-nfe-key-info.test.ts @@ -0,0 +1,278 @@ +import * as fc from "fast-check"; + +import { IBGE_UF_CODES } from "../_internals/constants/ibge-uf-codes"; +import { type StateCode } from "../_internals/constants/states"; +import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; +import { EMISSION_TYPES_BY_MODEL, FORBIDDEN_CODES, VALID_MODELS } from "./constants"; +import { getNfeKeyInfo, type NfeKeyInfo, type NfeKeyModel } from "./get-nfe-key-info"; + +const KEY_SP = "35170458716523000119550010000000121000123458"; +const KEY_RS = "43160472202112000136550000000010571048440722"; +const KEY_CPF_PADDED = "35170400040364478829550010000000121000123457"; + +const CHECK_DIGITS = Array.from({ length: 10 }, (_, digit) => String(digit)); + +const AUTHORIZATION_SITE_MODELS = new Set(["62", "66"]); + +const MODEL_EMISSION_TYPES: { model: string; emissionType: number }[] = VALID_MODELS.flatMap( + (model) => EMISSION_TYPES_BY_MODEL[model].map((emissionType) => ({ model, emissionType })), +); + +const buildNfeKey = (base: string): string => + CHECK_DIGITS.map((digit) => `${base}${digit}`).find((key) => getNfeKeyInfo(key) !== null) ?? ""; + +describe("getNfeKeyInfo", () => { + describe("should return null", () => { + test("when it is null", () => { + // @ts-expect-error: intentionally invalid input + expect(getNfeKeyInfo(null)).toBeNull(); + }); + + test("when it is undefined", () => { + // @ts-expect-error: intentionally invalid input + expect(getNfeKeyInfo()).toBeNull(); + }); + + test("when it is a number", () => { + // @ts-expect-error: intentionally invalid input + expect(getNfeKeyInfo(123)).toBeNull(); + }); + + test("when it is an empty string", () => { + expect(getNfeKeyInfo("")).toBeNull(); + }); + + test("when the check digit does not match", () => { + expect(getNfeKeyInfo(`${KEY_SP.slice(0, 43)}9`)).toBeNull(); + }); + + test("when the model is not one of the nine supported (model 99 with a matching check digit)", () => { + expect(getNfeKeyInfo("35170458716523000119990010000000121000123453")).toBeNull(); + }); + + test("when the document number is zero", () => { + expect(getNfeKeyInfo("35170458716523000119550010000000001000123457")).toBeNull(); + }); + + test("when tpEmis is 8, which the NF-e MOC does not assign, even with a matching check digit", () => { + expect(getNfeKeyInfo("35170458716523000119550010000000128000123455")).toBeNull(); + }); + + test("when tpEmis belongs to another model: 2 for a CT-e, 3 for a CT-e OS, 9 for an MDF-e, 3 for a BP-e", () => { + expect(getNfeKeyInfo("35170458716523000119570010000000122000123453")).toBeNull(); + expect(getNfeKeyInfo("35170458716523000119670010000000123000123454")).toBeNull(); + expect(getNfeKeyInfo("35170458716523000119580010000000129000123454")).toBeNull(); + expect(getNfeKeyInfo("35170458716523000119630010000000123000123450")).toBeNull(); + }); + + test("when the cNF of an NF-e is one rule B03-10 of the MOC forbids", () => { + expect(getNfeKeyInfo("35170458716523000119550010000000121000000003")).toBeNull(); + expect(getNfeKeyInfo("35170458716523000119550010000000121111111113")).toBeNull(); + expect(getNfeKeyInfo("35170458716523000119550010000000121123456781")).toBeNull(); + }); + + test("when the cNF of an NF-e equals its nNF, the second half of rule B03-10", () => { + expect(getNfeKeyInfo("35170458716523000119550010000123451000123458")).toBeNull(); + }); + + test("when the access key is otherwise invalid", () => { + expect(getNfeKeyInfo("not-a-key")).toBeNull(); + }); + }); + + describe("should return the parsed access key", () => { + test("for a NF-e access key (SP), the NFePHP `Keys::build` doc example also used in is-valid-nfe-key.test.ts", () => { + expect(getNfeKeyInfo(KEY_SP)).toEqual({ + stateCode: "SP", + year: 2017, + month: 4, + taxId: "58716523000119", + model: "55", + series: 1, + number: 12, + emissionType: 1, + code: "00012345", + checkDigit: 8, + }); + }); + + test("for a NF-e access key (RS), the NFePHP sped-cte `$infNFe->chave` example (NF-e referenced by a CT-e)", () => { + expect(getNfeKeyInfo(KEY_RS)).toEqual({ + stateCode: "RS", + year: 2016, + month: 4, + taxId: "72202112000136", + model: "55", + series: 0, + number: 1057, + emissionType: 1, + code: "04844072", + checkDigit: 2, + }); + }); + + test("accepting the NFe XML prefix and a whitespace mask", () => { + expect(getNfeKeyInfo(`NFe${KEY_SP}`)?.taxId).toBe("58716523000119"); + expect(getNfeKeyInfo("3517 0458 7165 2300 0119 5500 1000 0000 1210 0012 3458")?.number).toBe( + 12, + ); + }); + + test("accepting the XML Id prefix of every other covered document", () => { + expect(getNfeKeyInfo("CTe35170458716523000119570010000000121000123455")?.model).toBe("57"); + expect(getNfeKeyInfo("MDFe35170458716523000119580010000000121000123459")?.model).toBe("58"); + expect(getNfeKeyInfo("BPe35170458716523000119630010000000121000123453")?.model).toBe("63"); + expect(getNfeKeyInfo("NF3e35170458716523000119660010000000121000123454")?.model).toBe("66"); + expect(getNfeKeyInfo("NFCom35170458716523000119620010000000121000123450")?.model).toBe("62"); + }); + + test("for the CT-e models the SVC-SP authorises, whose MOC assigns tpEmis 8", () => { + expect(getNfeKeyInfo("35170458716523000119570010000000128000123452")?.emissionType).toBe(8); + expect(getNfeKeyInfo("35170458716523000119670010000000128000123455")?.emissionType).toBe(8); + expect(getNfeKeyInfo("35170458716523000119640010000000128000123454")?.emissionType).toBe(8); + }); + + test("for the MDF-e contingência Regime Especial NFF, tpEmis 3", () => { + expect(getNfeKeyInfo("35170458716523000119580010000000123000123455")?.emissionType).toBe(3); + }); + + test("keeping the cNF of a CT-e that rule B03-10 would forbid, since only the NF-e MOC states it", () => { + expect(getNfeKeyInfo("35170458716523000119570010000000121000000000")?.code).toBe("00000000"); + expect(getNfeKeyInfo("35170458716523000119570010000123451000123455")?.code).toBe("00012345"); + }); + + test("keeping the left zero padding of a CPF issuer, using a synthetic key with an 11-digit CPF left-padded to 14 digits in the tax id field and the check digit recalculated", () => { + expect(getNfeKeyInfo(KEY_CPF_PADDED)?.taxId).toBe("00040364478829"); + expect(getNfeKeyInfo(KEY_CPF_PADDED)?.taxId).toHaveLength(14); + }); + + test("for tpEmis 9, the off-line NFC-e contingency, same shape as the SP key with the tpEmis field changed and the check digit recalculated", () => { + expect(getNfeKeyInfo("35170458716523000119550010000000129000123453")?.emissionType).toBe(9); + }); + + test("for every other DF-e model (CT-e, MDF-e, GTV-e, NFC-e, CT-e OS), same shape as the SP key with the model field changed and the check digit recalculated", () => { + expect(getNfeKeyInfo("35170458716523000119570010000000121000123455")?.model).toBe("57"); + expect(getNfeKeyInfo("35170458716523000119580010000000121000123459")?.model).toBe("58"); + expect(getNfeKeyInfo("35170458716523000119630010000000121000123453")?.model).toBe("63"); + expect(getNfeKeyInfo("35170458716523000119640010000000121000123457")?.model).toBe("64"); + expect(getNfeKeyInfo("35170458716523000119650010000000121000123450")?.model).toBe("65"); + expect(getNfeKeyInfo("35170458716523000119670010000000121000123458")?.model).toBe("67"); + }); + + test("splitting nSiteAutoriz from the 7 digit cNF of an NFCom, per its Visão Geral §2.1.3", () => { + expect(getNfeKeyInfo("35170458716523000119620010000000121000123450")).toEqual({ + stateCode: "SP", + year: 2017, + month: 4, + taxId: "58716523000119", + model: "62", + series: 1, + number: 12, + emissionType: 1, + authorizationSite: 0, + code: "0012345", + checkDigit: 0, + }); + expect(getNfeKeyInfo("35170458716523000119620010000000121700123452")?.authorizationSite).toBe( + 7, + ); + }); + + test("splitting nSiteAutoriz from the 7 digit cNF of an NF3e, per its Visão Geral", () => { + expect(getNfeKeyInfo("35170458716523000119660010000000121000123454")).toEqual({ + stateCode: "SP", + year: 2017, + month: 4, + taxId: "58716523000119", + model: "66", + series: 1, + number: 12, + emissionType: 1, + authorizationSite: 0, + code: "0012345", + checkDigit: 4, + }); + }); + + test("without an authorizationSite property for a model whose key has no nSiteAutoriz", () => { + expect(getNfeKeyInfo(KEY_SP)).not.toHaveProperty("authorizationSite"); + }); + }); + + describe("properties", () => { + const parts = fc.tuple( + fc.constantFrom(...Object.keys(IBGE_UF_CODES)), + fc.stringMatching(/^[0-9]{2}$/), + fc.integer({ min: 1, max: 12 }), + fc.stringMatching(/^[0-9]{14}$/), + fc.constantFrom(...MODEL_EMISSION_TYPES), + fc.stringMatching(/^[0-9]{3}$/), + fc.integer({ min: 1, max: 999_999_999 }), + fc.stringMatching(/^[0-9]{8}$/), + ); + + test("should give back every field of a well-formed access key", () => { + fc.assert( + fc.property(parts, (fields) => { + const [uf, year, month, taxId, document, series, number, tail] = fields; + const { model, emissionType } = document; + const hasSite = AUTHORIZATION_SITE_MODELS.has(model); + const code = hasSite ? tail.slice(1) : tail; + + fc.pre(!FORBIDDEN_CODES.includes(code) && Number(code) !== number); + + const issuer = `${uf}${year}${String(month).padStart(2, "0")}${taxId}`; + const numbering = `${model}${series}${String(number).padStart(9, "0")}`; + const key = buildNfeKey(`${issuer}${numbering}${emissionType}${tail}`); + const parsed = getNfeKeyInfo(key); + + expect(parsed?.stateCode).toBe(IBGE_UF_CODES[uf]); + expect(parsed?.year).toBe(2000 + Number(year)); + expect(parsed?.month).toBe(month); + expect(parsed?.taxId).toBe(taxId); + expect(parsed?.model).toBe(model); + expect(parsed?.series).toBe(Number(series)); + expect(parsed?.number).toBe(number); + expect(parsed?.emissionType).toBe(emissionType); + expect(parsed?.authorizationSite).toBe(hasSite ? Number(tail.charAt(0)) : undefined); + expect(parsed?.code).toBe(code); + expect(parsed?.checkDigit).toBe(Number(key.charAt(43))); + }), + ); + }); + + test("should never throw and always return an access key or null", () => { + fc.assert( + fc.property(fc.anything(), (value) => { + const parsed = getNfeKeyInfo(value as string); + + expect(parsed === null || typeof parsed.taxId === "string").toBe(true); + }), + ); + }); + }); +}); + +describe("getNfeKeyInfo types", () => { + test("should take a string and return an NfeKeyInfo or null", () => { + expectTypeOf(getNfeKeyInfo).parameter(0).toEqualTypeOf(); + expectTypeOf(getNfeKeyInfo).returns.toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<{ + stateCode: StateCode; + year: number; + month: number; + taxId: string; + model: NfeKeyModel; + series: number; + number: number; + emissionType: number; + authorizationSite?: number; + code: string; + checkDigit: number; + }>(); + expectTypeOf().toEqualTypeOf< + "55" | "57" | "58" | "62" | "63" | "64" | "65" | "66" | "67" + >(); + expectTypeOf().toEqualTypeOf<(typeof VALID_MODELS)[number]>(); + }); +}); diff --git a/src/get-nfe-key-info/get-nfe-key-info.ts b/src/get-nfe-key-info/get-nfe-key-info.ts new file mode 100644 index 00000000..8995b7ac --- /dev/null +++ b/src/get-nfe-key-info/get-nfe-key-info.ts @@ -0,0 +1,202 @@ +import { IBGE_UF_CODES } from "../_internals/constants/ibge-uf-codes"; +import { NFE_KEY_LENGTH, XML_ID_PREFIX_REGEX } from "../_internals/constants/nfe-key"; +import { type StateCode } from "../_internals/constants/states"; +import { mod11 } from "../_internals/mod11/mod11"; +import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { + ABSENT_NUMBER, + AUTHORIZATION_SITE_MODELS, + EMISSION_TYPES_BY_MODEL, + FORBIDDEN_CODES, + FORBIDDEN_CODE_MODELS, + FORMAT_REGEX, + NUMBER_END, + NUMBER_START, + VALID_MODELS, +} from "./constants"; + +export type { StateCode } from "../_internals/constants/states"; + +/** + * The document models a DF-e access key can carry: `"55"` NF-e, `"57"` CT-e, `"58"` MDF-e, + * `"62"` NFCom, `"63"` BP-e, `"64"` GTV-e, `"65"` NFC-e, `"66"` NF3e and `"67"` CT-e OS. + * Spelled out instead of derived from `VALID_MODELS` because the allowlist is internal and API + * Extractor cannot name it in the public report; the type test of `get-nfe-key-info.test.ts` pins + * the two together so they cannot drift apart. + */ +export type NfeKeyModel = "55" | "57" | "58" | "62" | "63" | "64" | "65" | "66" | "67"; + +/** The fields `getNfeKeyInfo` reads out of a DF-e access key (chave de acesso). */ +export type NfeKeyInfo = { + /** Two letter code of the issuing state (UF), read from the IBGE UF code. */ + stateCode: StateCode; + /** Four digit issue year. */ + year: number; + /** Issue month, 1 to 12. */ + month: number; + /** The 14 digit CNPJ (or zero padded CPF) of the issuer. */ + taxId: string; + /** Document model: "55" NF-e, "57" CT-e, "58" MDF-e, "62" NFCom, "63" BP-e, "64" GTV-e, "65" NFC-e, "66" NF3e, "67" CT-e OS. */ + model: NfeKeyModel; + /** Document series, 0 to 999. */ + series: number; + /** Document number, 1 to 999999999. */ + number: number; + /** Emission type code (tpEmis), one of the codes the MOC of that model assigns. */ + emissionType: number; + /** + * Site of the authorizer that received the document (`nSiteAutoriz`), 0 to 9. Only NFCom + * (`"62"`) and NF3e (`"66"`) spend a digit of the key on it. + */ + authorizationSite?: number; + /** The numeric code (cNF) drawn by the issuer: 7 digits for NFCom and NF3e, 8 for the rest. */ + code: string; + /** The modulo 11 check digit of the key. */ + checkDigit: number; +}; + +const EMISSION_TYPE_INDEX = 34; + +const AUTHORIZATION_SITE_INDEX = 35; + +const SHORT_CODE_START = 36; + +const CODE_END = 43; + +const CHECK_DIGIT_INDEX = 43; + +const isForbiddenCode = (model: string, code: string, number: number): boolean => + FORBIDDEN_CODE_MODELS.includes(model) && + (FORBIDDEN_CODES.includes(code) || Number(code) === number); + +/** + * Parses a DF-e (Documento Fiscal eletrônico) access key (chave de acesso) into its fields. + * + * Covers every document whose access key is the same 44 digit string: NF-e (modelo 55), NFC-e + * (65), CT-e (57), MDF-e (58), CT-e OS (67), GTV-e (64), BP-e (63), NF3e (66) and NFCom (62). + * Accepts the same input forms as `isValidNfeKey` (the printed mask of 4 digit groups, split by + * whitespace, `.`, `-` or `/`, and the `NFe`, `CTe`, `MDFe`, `BPe`, `NF3e` and `NFCom` prefixes + * of the XML `Id` attribute) and returns `null` when the key is not valid. + * + * The emission type (`tpEmis`) is checked against the codes the MOC of that model assigns, so + * the accepted set changes with the model: 1 to 7 and 9 for NF-e and NFC-e, `{1, 3, 4, 5, 7, 8}` + * for the CT-e, `{1, 5, 7, 8}` for the CT-e OS and `{1, 2, 7, 8}` for the GTV-e (8 is the + * authorização pela SVC-SP of the CT-e MOC), `{1, 2, 3}` for the MDF-e and `{1, 2}` for the + * BP-e, the NF3e and the NFCom. + * + * NFCom and NF3e write `nSiteAutoriz` in position 36 and only 7 digits of `cNF` after it, so + * `authorizationSite` is filled for those two models and `code` is 7 characters long instead of + * 8; every other model leaves `authorizationSite` out and reads an 8 digit `code`. + * + * For NF-e and NFC-e the numeric code is also checked against rule B03-10 of the NF-e MOC, + * which forbids the twenty repeated and sequential codes it lists and a `cNF` equal to the + * document number. That rule arrived with NT 2019.001, so it can turn down a key authorised + * before it, and no other MOC states it, which is why it is not applied to the other models. + * A document number of all zeros is turned down for every model, following the leiaute rather + * than a choice of this library: `nNF` is typed `TNF` in `tiposBasico_v4.00.xsd`, whose pattern + * is `[1-9]{1}[0-9]{0,8}`, and the Anexo I of every other model repeats the same regex for its + * own number field (`nCT`, `nMDF`, `nBP`, `nNF`). + * + * @param {string} value - The access key value to be parsed. + * @returns {NfeKeyInfo | null} The parsed access key, or `null` when it is not valid. + * + * @see Official: https://www.confaz.fazenda.gov.br/legislacao/arquivo-manuais/moc7-visao-geral.pdf + * Manual de Orientação do Contribuinte (MOC) NF-e, "chave de acesso". + * @see Official: https://dfe-portal.svrs.rs.gov.br/NFE/Documentos + * NF-e schema package (PL_010b, NT2025.002 v1.30): `tiposBasico_v4.00.xsd`, the `TNF` and + * `TCodUfIBGE` types. + * @see Official: https://www.confaz.fazenda.gov.br/legislacao/ajustes/2007/AJ_009_07 + * Ajuste SINIEF 09/07, cláusula primeira, caput: the CT-e, modelo 57. + * @see Official: https://www.confaz.fazenda.gov.br/legislacao/ajustes/2019/AJ036_19 + * Ajuste SINIEF 36/19, cláusula primeira: the CT-e OS, modelo 67. + * @see Official: https://www.confaz.fazenda.gov.br/legislacao/ajustes/2020/ajuste-sinief-03-20 + * Ajuste SINIEF 03/20, cláusula primeira: the GTV-e, modelo 64. + * @see Official: https://dfe-portal.svrs.rs.gov.br/CTE/Documentos + * CT-e MOC 4.00, Anexo I ("MOC CTe 4.00 Anexo I - Leiaute e Regras de Validação"): the `tpEmis` + * domains D19, D27 and D15. Published by the SVRS dfe-portal, like the BP-e, NF3e and NFCom + * manuals below; the cte.fazenda.gov.br manual index answers "Sistema temporariamente + * indisponível" permanently. + * @see Official: https://dfe-portal.svrs.rs.gov.br/BPE/Documentos + * BP-e MOC 1.00b, Visão Geral and Anexo I: modelo 63. + * @see Official: https://dfe-portal.svrs.rs.gov.br/NF3e/Documentos + * NF3e MOC 1.00a, Visão Geral and Anexo I: modelo 66 and `nSiteAutoriz`. + * @see Official: https://dfe-portal.svrs.rs.gov.br/NFCOM/Documentos + * NFCom MOC 1.00a, Visão Geral and Anexo I: modelo 62 and `nSiteAutoriz`. + * @see Based on: https://github.com/nfephp-org/sped-common/blob/master/src/Keys.php + * NFePHP `Keys::build` reference implementation, source of the SP and RS test vectors. + * @see Based on: https://github.com/vmarchesin/br-validate-dfe-access-key + * Second reference implementation. + * + * @example + * ```typescript + * getNfeKeyInfo("35170458716523000119550010000000121000123458"); + * // { stateCode: "SP", year: 2017, month: 4, taxId: "58716523000119", model: "55", + * // series: 1, number: 12, emissionType: 1, code: "00012345", checkDigit: 8 } + * + * getNfeKeyInfo("invalid"); // null + * ``` + */ +export const getNfeKeyInfo = (value: string): NfeKeyInfo | null => { + if (typeof value !== "string") return null; + + const body = value.trim().replace(XML_ID_PREFIX_REGEX, "").trimStart(); + + if (!FORMAT_REGEX.test(body)) return null; + + const digits = sanitizeToDigits(body); + + if (digits.length !== NFE_KEY_LENGTH) return null; + + const uf = digits.slice(0, 2); + + const stateCode = IBGE_UF_CODES[uf]; + + if (stateCode === undefined) return null; + + const month = Number(digits.slice(4, 6)); + + if (month < 1 || month > 12) return null; + + const modelDigits = digits.slice(20, 22); + const model = VALID_MODELS.find((candidate) => candidate === modelDigits); + + if (model === undefined) return null; + + if (digits.slice(NUMBER_START, NUMBER_END) === ABSENT_NUMBER) return null; + + const emissionType = Number(digits[EMISSION_TYPE_INDEX]); + + if (!EMISSION_TYPES_BY_MODEL[model].includes(emissionType)) return null; + + const hasAuthorizationSite = AUTHORIZATION_SITE_MODELS.includes(model); + const code = digits.slice( + hasAuthorizationSite ? SHORT_CODE_START : AUTHORIZATION_SITE_INDEX, + CODE_END, + ); + const number = Number(digits.slice(NUMBER_START, NUMBER_END)); + + if (isForbiddenCode(model, code, number)) return null; + + const checkDigit = Number(digits[CHECK_DIGIT_INDEX]); + + if (mod11(digits.slice(0, CHECK_DIGIT_INDEX), { variant: "arrecadacao" }) !== checkDigit) { + return null; + } + + const parsed: NfeKeyInfo = { + stateCode, + year: 2000 + Number(digits.slice(2, 4)), + month, + taxId: digits.slice(6, 20), + model, + series: Number(digits.slice(22, 25)), + number, + emissionType, + code, + checkDigit, + }; + + if (hasAuthorizationSite) parsed.authorizationSite = Number(digits[AUTHORIZATION_SITE_INDEX]); + + return parsed; +}; diff --git a/src/parse-pix-key/constants.ts b/src/get-pix-key-info/constants.ts similarity index 100% rename from src/parse-pix-key/constants.ts rename to src/get-pix-key-info/constants.ts diff --git a/src/parse-pix-key/parse-pix-key.test.ts b/src/get-pix-key-info/get-pix-key-info.test.ts similarity index 61% rename from src/parse-pix-key/parse-pix-key.test.ts rename to src/get-pix-key-info/get-pix-key-info.test.ts index 10a50ae4..8595a78d 100644 --- a/src/parse-pix-key/parse-pix-key.test.ts +++ b/src/get-pix-key-info/get-pix-key-info.test.ts @@ -5,7 +5,7 @@ import { formatCnpj } from "../format-cnpj/format-cnpj"; import { generateCnpj } from "../generate-cnpj/generate-cnpj"; import { generateCpf } from "../generate-cpf/generate-cpf"; import { generatePhone } from "../generate-phone/generate-phone"; -import { type PixKey, type PixKeyType, parsePixKey } from "./parse-pix-key"; +import { type PixKeyInfo, type PixKeyType, getPixKeyInfo } from "./get-pix-key-info"; const AMBIGUOUS = "51998259765"; @@ -25,124 +25,124 @@ const buildPixKey = (kind: (typeof PIX_KEY_KINDS)[number], email: string, evp: s return kind === "email" ? email : evp; }; -describe("parsePixKey", () => { +describe("getPixKeyInfo", () => { describe("should return null", () => { test("when it is an empty or blank string", () => { - expect(parsePixKey("")).toBeNull(); - expect(parsePixKey(" ")).toBeNull(); + expect(getPixKeyInfo("")).toBeNull(); + expect(getPixKeyInfo(" ")).toBeNull(); }); test("when it is null", () => { // @ts-expect-error: intentionally invalid input - expect(parsePixKey(null)).toBeNull(); + expect(getPixKeyInfo(null)).toBeNull(); }); test("when it is undefined", () => { // @ts-expect-error: intentionally invalid input - expect(parsePixKey()).toBeNull(); + expect(getPixKeyInfo()).toBeNull(); }); test("when it is a number", () => { // @ts-expect-error: intentionally invalid input - expect(parsePixKey(12_345_678_909)).toBeNull(); + expect(getPixKeyInfo(12_345_678_909)).toBeNull(); }); test("when it is a boolean, an object or an array", () => { // @ts-expect-error: intentionally invalid input - expect(parsePixKey(true)).toBeNull(); + expect(getPixKeyInfo(true)).toBeNull(); // @ts-expect-error: intentionally invalid input - expect(parsePixKey({})).toBeNull(); + expect(getPixKeyInfo({})).toBeNull(); // @ts-expect-error: intentionally invalid input - expect(parsePixKey([])).toBeNull(); + expect(getPixKeyInfo([])).toBeNull(); }); test("when it is an invalid CPF", () => { - expect(parsePixKey("11257245286")).toBeNull(); + expect(getPixKeyInfo("11257245286")).toBeNull(); }); test("when it is an invalid CNPJ", () => { - expect(parsePixKey("11222333000182")).toBeNull(); + expect(getPixKeyInfo("11222333000182")).toBeNull(); }); test("when it is an invalid e-mail", () => { - expect(parsePixKey("fulano@")).toBeNull(); - expect(parsePixKey("@example.com")).toBeNull(); - expect(parsePixKey("fulano@example")).toBeNull(); + expect(getPixKeyInfo("fulano@")).toBeNull(); + expect(getPixKeyInfo("@example.com")).toBeNull(); + expect(getPixKeyInfo("fulano@example")).toBeNull(); }); test("when the e-mail is longer than 77 characters", () => { - expect(parsePixKey(`${"a".repeat(66)}@example.com`)).toBeNull(); + expect(getPixKeyInfo(`${"a".repeat(66)}@example.com`)).toBeNull(); }); test("when the random key is not a UUID", () => { - expect(parsePixKey("71c7d9be4b854e439f1c1f3b8b4e9a2d")).toBeNull(); - expect(parsePixKey("71c7d9be-4b85-4e43-9f1c-1f3b8b4e9a2")).toBeNull(); - expect(parsePixKey("71c7d9be-4b85-4e43-9f1c-1f3b8b4e9azz")).toBeNull(); + expect(getPixKeyInfo("71c7d9be4b854e439f1c1f3b8b4e9a2d")).toBeNull(); + expect(getPixKeyInfo("71c7d9be-4b85-4e43-9f1c-1f3b8b4e9a2")).toBeNull(); + expect(getPixKeyInfo("71c7d9be-4b85-4e43-9f1c-1f3b8b4e9azz")).toBeNull(); }); test("when the phone has an invalid area code", () => { - expect(parsePixKey("(00) 98765-4321")).toBeNull(); + expect(getPixKeyInfo("(00) 98765-4321")).toBeNull(); }); test("when the phone is a landline, since the manual registers a mobile number", () => { - expect(parsePixKey("(11) 3000-0000")).toBeNull(); - expect(parsePixKey("+551130000000")).toBeNull(); - expect(parsePixKey("1130000000")).toBeNull(); + expect(getPixKeyInfo("(11) 3000-0000")).toBeNull(); + expect(getPixKeyInfo("+551130000000")).toBeNull(); + expect(getPixKeyInfo("1130000000")).toBeNull(); }); test("when it is free text", () => { - expect(parsePixKey("chave pix")).toBeNull(); - expect(parsePixKey("---")).toBeNull(); + expect(getPixKeyInfo("chave pix")).toBeNull(); + expect(getPixKeyInfo("---")).toBeNull(); }); test("when a phone number is buried in surrounding text", () => { - expect(parsePixKey("abc(11) 98765-4321xyz")).toBeNull(); - expect(parsePixKey("tel: (11) 98765-4321")).toBeNull(); + expect(getPixKeyInfo("abc(11) 98765-4321xyz")).toBeNull(); + expect(getPixKeyInfo("tel: (11) 98765-4321")).toBeNull(); }); test("when a CPF is buried in surrounding text", () => { - expect(parsePixKey("abc123.456.789-09")).toBeNull(); - expect(parsePixKey("CPF 123.456.789-09")).toBeNull(); + expect(getPixKeyInfo("abc123.456.789-09")).toBeNull(); + expect(getPixKeyInfo("CPF 123.456.789-09")).toBeNull(); }); test("when a CPF is written with separators outside the documented positions", () => { - expect(parsePixKey("1.2.3.4.5.6.7.8.9.0.9")).toBeNull(); - expect(parsePixKey("123/456/789/09")).toBeNull(); + expect(getPixKeyInfo("1.2.3.4.5.6.7.8.9.0.9")).toBeNull(); + expect(getPixKeyInfo("123/456/789/09")).toBeNull(); }); }); describe("should return a CPF", () => { test("when it is masked", () => { - expect(parsePixKey("123.456.789-09")).toEqual({ type: "cpf", value: "12345678909" }); + expect(getPixKeyInfo("123.456.789-09")).toEqual({ type: "cpf", value: "12345678909" }); }); test("when it is unmasked", () => { - expect(parsePixKey("40364478829")).toEqual({ type: "cpf", value: "40364478829" }); + expect(getPixKeyInfo("40364478829")).toEqual({ type: "cpf", value: "40364478829" }); }); test("when surrounded by whitespace", () => { - expect(parsePixKey(" 40364478829 ")).toEqual({ type: "cpf", value: "40364478829" }); + expect(getPixKeyInfo(" 40364478829 ")).toEqual({ type: "cpf", value: "40364478829" }); }); }); describe("should return a CNPJ", () => { test("when it is masked", () => { - expect(parsePixKey("00.038.166/0001-05")).toEqual({ + expect(getPixKeyInfo("00.038.166/0001-05")).toEqual({ type: "cnpj", value: "00038166000105", }); }); test("when it is unmasked", () => { - expect(parsePixKey("00038166000105")).toEqual({ + expect(getPixKeyInfo("00038166000105")).toEqual({ type: "cnpj", value: "00038166000105", }); }); test("when it is the alphanumeric format of the manual", () => { - expect(parsePixKey("12ABC34501DE35")).toEqual({ type: "cnpj", value: "12ABC34501DE35" }); - expect(parsePixKey("12.abc.345/01de-35")).toEqual({ + expect(getPixKeyInfo("12ABC34501DE35")).toEqual({ type: "cnpj", value: "12ABC34501DE35" }); + expect(getPixKeyInfo("12.abc.345/01de-35")).toEqual({ type: "cnpj", value: "12ABC34501DE35", }); @@ -151,32 +151,35 @@ describe("parsePixKey", () => { describe("should resolve the CNPJ and phone ambiguity", () => { test("should read a valid CNPJ as a CNPJ even when it starts with 0055", () => { - expect(parsePixKey("00551760871813")).toEqual({ type: "cnpj", value: "00551760871813" }); - expect(parsePixKey("00.551.760/8718-13")).toEqual({ type: "cnpj", value: "00551760871813" }); + expect(getPixKeyInfo("00551760871813")).toEqual({ type: "cnpj", value: "00551760871813" }); + expect(getPixKeyInfo("00.551.760/8718-13")).toEqual({ + type: "cnpj", + value: "00551760871813", + }); }); test("should still read a 0055 prefixed mobile number as a phone", () => { - expect(parsePixKey("005511987654321")).toEqual({ + expect(getPixKeyInfo("005511987654321")).toEqual({ type: "phone", value: "+5511987654321", }); }); test("should return null for a 0055 prefixed value that is neither a valid CNPJ nor a mobile number", () => { - expect(parsePixKey("00551133334444")).toBeNull(); + expect(getPixKeyInfo("00551133334444")).toBeNull(); }); }); describe("should return an e-mail", () => { test("when it is the example of the manual", () => { - expect(parsePixKey("fulano_da_silva.recebedor@example.com")).toEqual({ + expect(getPixKeyInfo("fulano_da_silva.recebedor@example.com")).toEqual({ type: "email", value: "fulano_da_silva.recebedor@example.com", }); }); test("when it is uppercased or padded", () => { - expect(parsePixKey(" Fulano@Example.COM ")).toEqual({ + expect(getPixKeyInfo(" Fulano@Example.COM ")).toEqual({ type: "email", value: "fulano@example.com", }); @@ -186,39 +189,39 @@ describe("parsePixKey", () => { const email = `${"a".repeat(65)}@example.com`; expect(email).toHaveLength(77); - expect(parsePixKey(email)).toEqual({ type: "email", value: email }); + expect(getPixKeyInfo(email)).toEqual({ type: "email", value: email }); }); }); describe("should return a phone", () => { test("when it is the example of the manual", () => { - expect(parsePixKey("+5561912345678")).toEqual({ + expect(getPixKeyInfo("+5561912345678")).toEqual({ type: "phone", value: "+5561912345678", }); }); test("when it is masked", () => { - expect(parsePixKey("(11) 98765-4321")).toEqual({ + expect(getPixKeyInfo("(11) 98765-4321")).toEqual({ type: "phone", value: "+5511987654321", }); }); test("when it is bare", () => { - expect(parsePixKey("11987654321")).toEqual({ type: "phone", value: "+5511987654321" }); + expect(getPixKeyInfo("11987654321")).toEqual({ type: "phone", value: "+5511987654321" }); }); test("when it carries the country code in every accepted form", () => { - expect(parsePixKey("+55 11 98765-4321")).toEqual({ + expect(getPixKeyInfo("+55 11 98765-4321")).toEqual({ type: "phone", value: "+5511987654321", }); - expect(parsePixKey("005511987654321")).toEqual({ + expect(getPixKeyInfo("005511987654321")).toEqual({ type: "phone", value: "+5511987654321", }); - expect(parsePixKey("5511987654321")).toEqual({ + expect(getPixKeyInfo("5511987654321")).toEqual({ type: "phone", value: "+5511987654321", }); @@ -226,7 +229,7 @@ describe("parsePixKey", () => { test("and never exceed the 14 characters of the E.164 form", () => { for (let index = 0; index < 200; index++) { - const key = parsePixKey(`+55${generatePhone("mobile")}`); + const key = getPixKeyInfo(`+55${generatePhone("mobile")}`); expect(key?.type).toBe("phone"); expect(key?.value.length).toBeLessThanOrEqual(14); @@ -236,21 +239,21 @@ describe("parsePixKey", () => { describe("should return a random key", () => { test("when it is a lowercase UUID", () => { - expect(parsePixKey("71c7d9be-4b85-4e43-9f1c-1f3b8b4e9a2d")).toEqual({ + expect(getPixKeyInfo("71c7d9be-4b85-4e43-9f1c-1f3b8b4e9a2d")).toEqual({ type: "evp", value: "71c7d9be-4b85-4e43-9f1c-1f3b8b4e9a2d", }); }); test("when it is uppercased, lowercasing it", () => { - expect(parsePixKey("71C7D9BE-4B85-4E43-9F1C-1F3B8B4E9A2D")).toEqual({ + expect(getPixKeyInfo("71C7D9BE-4B85-4E43-9F1C-1F3B8B4E9A2D")).toEqual({ type: "evp", value: "71c7d9be-4b85-4e43-9f1c-1f3b8b4e9a2d", }); }); test("when it is the example of the manual, whose version nibble is not 4", () => { - expect(parsePixKey("123e4567-e12b-12d1-a456-426655440000")).toEqual({ + expect(getPixKeyInfo("123e4567-e12b-12d1-a456-426655440000")).toEqual({ type: "evp", value: "123e4567-e12b-12d1-a456-426655440000", }); @@ -259,29 +262,29 @@ describe("parsePixKey", () => { describe("should resolve the CPF and phone ambiguity", () => { test("preferring the CPF when the value is valid as both", () => { - expect(parsePixKey(AMBIGUOUS)).toEqual({ type: "cpf", value: AMBIGUOUS }); + expect(getPixKeyInfo(AMBIGUOUS)).toEqual({ type: "cpf", value: AMBIGUOUS }); }); test("preferring the phone when it starts with the country code", () => { - expect(parsePixKey(`+55${AMBIGUOUS}`)).toEqual({ + expect(getPixKeyInfo(`+55${AMBIGUOUS}`)).toEqual({ type: "phone", value: `+55${AMBIGUOUS}`, }); - expect(parsePixKey(`0055${AMBIGUOUS}`)).toEqual({ + expect(getPixKeyInfo(`0055${AMBIGUOUS}`)).toEqual({ type: "phone", value: `+55${AMBIGUOUS}`, }); }); test("preferring the phone when the DDD is written between parentheses", () => { - expect(parsePixKey("(51) 99825-9765")).toEqual({ + expect(getPixKeyInfo("(51) 99825-9765")).toEqual({ type: "phone", value: `+55${AMBIGUOUS}`, }); }); test("keeping the CPF when it is written with its own mask", () => { - expect(parsePixKey("519.982.597-65")).toEqual({ type: "cpf", value: AMBIGUOUS }); + expect(getPixKeyInfo("519.982.597-65")).toEqual({ type: "cpf", value: AMBIGUOUS }); }); }); @@ -290,7 +293,7 @@ describe("parsePixKey", () => { for (let index = 0; index < 200; index++) { const cpf = generateCpf(); - expect(parsePixKey(cpf)?.value).toBe(cpf); + expect(getPixKeyInfo(cpf)?.value).toBe(cpf); } }); @@ -298,7 +301,7 @@ describe("parsePixKey", () => { for (let index = 0; index < 200; index++) { const cnpj = generateCnpj(); - expect(parsePixKey(formatCnpj(cnpj))).toEqual({ type: "cnpj", value: cnpj }); + expect(getPixKeyInfo(formatCnpj(cnpj))).toEqual({ type: "cnpj", value: cnpj }); } }); }); @@ -311,7 +314,7 @@ describe("parsePixKey", () => { test("should recognize every kind of key the DICT defines", () => { fc.assert( fc.property(keys, ([kind, email, evp]) => { - const parsed = parsePixKey(buildPixKey(kind, email, evp)); + const parsed = getPixKeyInfo(buildPixKey(kind, email, evp)); expect(parsed?.type).toBe(kind); }), @@ -321,8 +324,8 @@ describe("parsePixKey", () => { test("should return a canonical value that parses back to itself", () => { fc.assert( fc.property(keys, ([kind, email, evp]) => { - const parsed = parsePixKey(buildPixKey(kind, email, evp)); - const again = parsePixKey(parsed?.value ?? ""); + const parsed = getPixKeyInfo(buildPixKey(kind, email, evp)); + const again = getPixKeyInfo(parsed?.value ?? ""); expect(again?.type).toBe(parsed?.type); expect(again?.value).toBe(parsed?.value); @@ -334,8 +337,8 @@ describe("parsePixKey", () => { fc.assert( fc.property(keys, ([kind, email, evp]) => { const key = buildPixKey(kind, email, evp); - const parsed = parsePixKey(key); - const shouted = parsePixKey(` ${key.toUpperCase()} `); + const parsed = getPixKeyInfo(key); + const shouted = getPixKeyInfo(` ${key.toUpperCase()} `); expect(shouted?.value).toBe(parsed?.value); }), @@ -345,7 +348,7 @@ describe("parsePixKey", () => { test("should never throw and always return a Pix key or null", () => { fc.assert( fc.property(fc.anything(), (value) => { - const parsed = parsePixKey(value as string); + const parsed = getPixKeyInfo(value as string); expect(parsed === null || typeof parsed.value === "string").toBe(true); }), @@ -354,14 +357,14 @@ describe("parsePixKey", () => { }); }); -describe("parsePixKey types", () => { +describe("getPixKeyInfo types", () => { test("should take a string and return a Pix key or null", () => { - expectTypeOf(parsePixKey).parameter(0).toEqualTypeOf(); - expectTypeOf(parsePixKey).returns.toEqualTypeOf(); + expectTypeOf(getPixKeyInfo).parameter(0).toEqualTypeOf(); + expectTypeOf(getPixKeyInfo).returns.toEqualTypeOf(); }); test("should restrict the Pix key shape and its type", () => { - expectTypeOf().toEqualTypeOf<{ type: PixKeyType; value: string }>(); + expectTypeOf().toEqualTypeOf<{ type: PixKeyType; value: string }>(); expectTypeOf().toEqualTypeOf<"cpf" | "cnpj" | "email" | "phone" | "evp">(); }); }); diff --git a/src/parse-pix-key/parse-pix-key.ts b/src/get-pix-key-info/get-pix-key-info.ts similarity index 78% rename from src/parse-pix-key/parse-pix-key.ts rename to src/get-pix-key-info/get-pix-key-info.ts index 5917d6c0..658f7cb5 100644 --- a/src/parse-pix-key/parse-pix-key.ts +++ b/src/get-pix-key-info/get-pix-key-info.ts @@ -8,11 +8,11 @@ import { isValidPhone } from "../is-valid-phone/is-valid-phone"; import { parseCnpj } from "../parse-cnpj/parse-cnpj"; import { CPF_SYNTAX_REGEX, EMAIL_MAX_LENGTH, EVP_REGEX, PHONE_SYNTAX_REGEX } from "./constants"; -/** The kinds of Pix key `parsePixKey` recognizes. */ +/** The kinds of Pix key `getPixKeyInfo` recognizes. */ export type PixKeyType = "cpf" | "cnpj" | "email" | "phone" | "evp"; -/** A Pix key recognized by `parsePixKey`, normalized to the canonical DICT form of its kind. */ -export type PixKey = { +/** A Pix key recognized by `getPixKeyInfo`, normalized to the canonical DICT form of its kind. */ +export type PixKeyInfo = { /** Which kind of Pix key the value was recognized as. */ type: PixKeyType; /** The key in the canonical DICT form for its kind. */ @@ -24,9 +24,9 @@ export type PixKey = { * characters of the usual masks, as the E.164 mobile key of the DICT. * * @param {string} trimmed - The trimmed value to read. - * @returns {PixKey|null} The phone key, or `null` when the value is not a mobile number. + * @returns {PixKeyInfo|null} The phone key, or `null` when the value is not a mobile number. */ -const resolvePhoneKey = (trimmed: string): PixKey | null => { +const resolvePhoneKey = (trimmed: string): PixKeyInfo | null => { if (!PHONE_SYNTAX_REGEX.test(trimmed)) return null; const national = normalizePhone(trimmed); @@ -67,25 +67,27 @@ const resolvePhoneKey = (trimmed: string): PixKey | null => { * a CPF, even when its digits carry a valid CPF check digit. * * @param {string} value - The Pix key to be parsed. - * @returns {PixKey|null} The normalized key, or `null` when the value is not a valid Pix key. + * @returns {PixKeyInfo|null} The normalized key, or `null` when the value is not a valid Pix key. * * @example * ```typescript - * parsePixKey("123.456.789-09"); // { type: "cpf", value: "12345678909" } - * parsePixKey("Fulano@Example.COM "); // { type: "email", value: "fulano@example.com" } - * parsePixKey("(11) 98765-4321"); // { type: "phone", value: "+5511987654321" } - * parsePixKey("71C7D9BE-4B85-4E43-9F1C-1F3B8B4E9A2D"); + * getPixKeyInfo("123.456.789-09"); // { type: "cpf", value: "12345678909" } + * getPixKeyInfo("Fulano@Example.COM "); // { type: "email", value: "fulano@example.com" } + * getPixKeyInfo("(11) 98765-4321"); // { type: "phone", value: "+5511987654321" } + * getPixKeyInfo("71C7D9BE-4B85-4E43-9F1C-1F3B8B4E9A2D"); * // { type: "evp", value: "71c7d9be-4b85-4e43-9f1c-1f3b8b4e9a2d" } - * parsePixKey("51998259765"); // { type: "cpf", value: "51998259765" } (also a valid phone) - * parsePixKey("+5551998259765"); // { type: "phone", value: "+5551998259765" } + * getPixKeyInfo("51998259765"); // { type: "cpf", value: "51998259765" } (also a valid phone) + * getPixKeyInfo("+5551998259765"); // { type: "phone", value: "+5551998259765" } * ``` * * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/pix/Regulamento_Pix/II_ManualdePadroesparaIniciacaodoPix.pdf - * @see Official: https://github.com/bacen/pix-dict-api DICT (Diretório de Identificadores de - * Contas Transacionais) OpenAPI spec, key format reference. - * @see Official: https://github.com/bacen/pix-api Pix (SPI) OpenAPI spec. + * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/pix/API-DICT.html + * DICT (Diretório de Identificadores de Contas Transacionais) API specification, key format + * reference. + * @see Official: https://github.com/bacen/pix-api + * Pix (SPI) OpenAPI spec. */ -export const parsePixKey = (value: string): PixKey | null => { +export const getPixKeyInfo = (value: string): PixKeyInfo | null => { if (typeof value !== "string") return null; const trimmed = value.trim(); diff --git a/src/parse-pix-payload/parse-pix-payload.test.ts b/src/get-pix-payload-info/get-pix-payload-info.test.ts similarity index 78% rename from src/parse-pix-payload/parse-pix-payload.test.ts rename to src/get-pix-payload-info/get-pix-payload-info.test.ts index 73c4c1b7..79b1765f 100644 --- a/src/parse-pix-payload/parse-pix-payload.test.ts +++ b/src/get-pix-payload-info/get-pix-payload-info.test.ts @@ -4,7 +4,11 @@ import { crc16Ccitt } from "../_internals/crc16-ccitt/crc16-ccitt"; import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; import { generateCpf } from "../generate-cpf/generate-cpf"; import { generatePixPayload } from "../generate-pix-payload/generate-pix-payload"; -import { type PixPayload, type PixPointOfInitiation, parsePixPayload } from "./parse-pix-payload"; +import { + type PixPayloadInfo, + type PixPointOfInitiation, + getPixPayloadInfo, +} from "./get-pix-payload-info"; const BACEN_STATIC = "00020126580014br.gov.bcb.pix0136123e4567-e12b-12d1-a456-4266554400005204000053039865802BR5913Fulano de Tal6008BRASILIA62070503***63041D3D"; @@ -166,60 +170,60 @@ const buildPayloadWithMerchantCity = (merchantCity: string): string => { return withoutCrc + crc16Ccitt(withoutCrc); }; -describe("parsePixPayload", () => { +describe("getPixPayloadInfo", () => { describe("should return null", () => { test("when it is an empty or blank string", () => { - expect(parsePixPayload("")).toBeNull(); - expect(parsePixPayload(" ")).toBeNull(); + expect(getPixPayloadInfo("")).toBeNull(); + expect(getPixPayloadInfo(" ")).toBeNull(); }); test("when it is null", () => { // @ts-expect-error: intentionally invalid input - expect(parsePixPayload(null)).toBeNull(); + expect(getPixPayloadInfo(null)).toBeNull(); }); test("when it is undefined", () => { // @ts-expect-error: intentionally invalid input - expect(parsePixPayload()).toBeNull(); + expect(getPixPayloadInfo()).toBeNull(); }); test("when it is a number", () => { // @ts-expect-error: intentionally invalid input - expect(parsePixPayload(20_250_101)).toBeNull(); + expect(getPixPayloadInfo(20_250_101)).toBeNull(); }); test("when it is a boolean, an object or an array", () => { // @ts-expect-error: intentionally invalid input - expect(parsePixPayload(true)).toBeNull(); + expect(getPixPayloadInfo(true)).toBeNull(); // @ts-expect-error: intentionally invalid input - expect(parsePixPayload({})).toBeNull(); + expect(getPixPayloadInfo({})).toBeNull(); // @ts-expect-error: intentionally invalid input - expect(parsePixPayload([])).toBeNull(); + expect(getPixPayloadInfo([])).toBeNull(); }); test("when the CRC does not match", () => { - expect(parsePixPayload(BACEN_STATIC.replace(/1D3D$/, "1D3E"))).toBeNull(); + expect(getPixPayloadInfo(BACEN_STATIC.replace(/1D3D$/, "1D3E"))).toBeNull(); }); test("when it is free text", () => { - expect(parsePixPayload("pix copia e cola")).toBeNull(); + expect(getPixPayloadInfo("pix copia e cola")).toBeNull(); }); test("when the key object is present but empty", () => { const merchantAccountInformation = tlv("00", "br.gov.bcb.pix") + tlv("01", ""); - expect(parsePixPayload(buildPayload(merchantAccountInformation))).toBeNull(); + expect(getPixPayloadInfo(buildPayload(merchantAccountInformation))).toBeNull(); }); test("when the url object is present but empty", () => { const merchantAccountInformation = tlv("00", "br.gov.bcb.pix") + tlv("25", ""); - expect(parsePixPayload(buildPayload(merchantAccountInformation))).toBeNull(); + expect(getPixPayloadInfo(buildPayload(merchantAccountInformation))).toBeNull(); }); test("when the merchant account information carries both a key and a url", () => { expect( - parsePixPayload( + getPixPayloadInfo( "00020101021226500014br.gov.bcb.pix0107a@b.com2517pix.example.com/x5204000053039865802BR5901A6001B62070503***63049A4B", ), ).toBeNull(); @@ -227,87 +231,95 @@ describe("parsePixPayload", () => { test("when the url is not a PSP location (scheme, whitespace, host without a dot)", () => { expect( - parsePixPayload( + getPixPayloadInfo( "00020101021226470014br.gov.bcb.pix2525https://pix.example.com/x5204000053039865802BR5901A6001B62070503***6304F843", ), ).toBeNull(); expect( - parsePixPayload( + getPixPayloadInfo( "00020101021226390014br.gov.bcb.pix2517pix example.com/x5204000053039865802BR5901A6001B62070503***6304C8E4", ), ).toBeNull(); expect( - parsePixPayload( + getPixPayloadInfo( "00020101021226330014br.gov.bcb.pix2511localhost/x5204000053039865802BR5901A6001B62070503***630494D9", ), ).toBeNull(); }); test("when the fss of a Pix Saque is not the 8 digits of an ISPB", () => { - expect(parsePixPayload(buildWithdrawalPayload("1234567", "0.00"))).toBeNull(); - expect(parsePixPayload(buildWithdrawalPayload("123456789", "0.00"))).toBeNull(); - expect(parsePixPayload(buildWithdrawalPayload("1234567x", "0.00"))).toBeNull(); + expect(getPixPayloadInfo(buildWithdrawalPayload("1234567", "0.00"))).toBeNull(); + expect(getPixPayloadInfo(buildWithdrawalPayload("123456789", "0.00"))).toBeNull(); + expect(getPixPayloadInfo(buildWithdrawalPayload("1234567x", "0.00"))).toBeNull(); + }); + + test("when the fss of a Pix Saque is written next to a PSP location", () => { + const payload = + "00020126600014br.gov.bcb.pix2526pix.example.com/qr/v2/12340308123456785204000053039865802BR5913Fulano de Tal6008BRASILIA62070503***6304DA55"; + + expect(hasValidCrc(payload)).toBe(true); + expect(getPixPayloadInfo(payload)).toBeNull(); }); test("when the additional data template is malformed", () => { const merchantAccountInformation = tlv("00", "br.gov.bcb.pix") + tlv("01", "some-key"); - expect(parsePixPayload(buildPayload(merchantAccountInformation, "9"))).toBeNull(); + expect(getPixPayloadInfo(buildPayload(merchantAccountInformation, "9"))).toBeNull(); }); test("when a merchant account information template is malformed TLV, without throwing", () => { - expect(parsePixPayload(buildPayload("XY"))).toBeNull(); + expect(getPixPayloadInfo(buildPayload("XY"))).toBeNull(); }); test("when a merchant account information template is well-formed but carries no GUI, without throwing", () => { const merchantAccountInformation = tlv("01", "12345678909"); - expect(parsePixPayload(buildPayload(merchantAccountInformation))).toBeNull(); + expect(getPixPayloadInfo(buildPayload(merchantAccountInformation))).toBeNull(); }); test("when the country code field is entirely absent, without throwing", () => { - expect(parsePixPayload(buildPayloadWithoutCountryCode())).toBeNull(); + expect(getPixPayloadInfo(buildPayloadWithoutCountryCode())).toBeNull(); }); test("when the CRC tag id is not 6304, even with an otherwise self-consistent checksum", () => { - expect(parsePixPayload(buildPayloadWithCrcTag("9904"))).toBeNull(); + expect(getPixPayloadInfo(buildPayloadWithCrcTag("9904"))).toBeNull(); }); test("when the transaction amount is longer than 13 characters", () => { - expect(parsePixPayload(buildPayloadWithAmount("99999999999.99"))).toBeNull(); + expect(getPixPayloadInfo(buildPayloadWithAmount("99999999999.99"))).toBeNull(); }); test("when the transaction amount is not written as a plain decimal number", () => { - expect(parsePixPayload(buildPayloadWithAmount("+1.00"))).toBeNull(); - expect(parsePixPayload(buildPayloadWithAmount(" 1.00"))).toBeNull(); - expect(parsePixPayload(buildPayloadWithAmount("1.00x"))).toBeNull(); - expect(parsePixPayload(buildPayloadWithAmount("abc"))).toBeNull(); + expect(getPixPayloadInfo(buildPayloadWithAmount("+1.00"))).toBeNull(); + expect(getPixPayloadInfo(buildPayloadWithAmount(" 1.00"))).toBeNull(); + expect(getPixPayloadInfo(buildPayloadWithAmount("1.00x"))).toBeNull(); + expect(getPixPayloadInfo(buildPayloadWithAmount("abc"))).toBeNull(); }); test("when the transaction amount states more than two decimal places", () => { - expect(parsePixPayload(buildPayloadWithAmount("1.234"))).toBeNull(); + expect(getPixPayloadInfo(buildPayloadWithAmount("1.234"))).toBeNull(); }); test("when a key payload states a transaction amount of zero without the fss of a Pix Saque", () => { expect(hasValidCrc(buildPayloadWithAmount("0.00"))).toBe(true); - expect(parsePixPayload(buildPayloadWithAmount("0.00"))).toBeNull(); - expect(parsePixPayload(buildPayloadWithAmount("0"))).toBeNull(); - expect(parsePixPayload(buildPayloadWithAmount("0.0"))).toBeNull(); + expect(getPixPayloadInfo(buildPayloadWithAmount("0.00"))).toBeNull(); + expect(getPixPayloadInfo(buildPayloadWithAmount("0"))).toBeNull(); + expect(getPixPayloadInfo(buildPayloadWithAmount("0.0"))).toBeNull(); }); test("when the merchant name is present but empty", () => { - expect(parsePixPayload(buildPayloadWithMerchantName(""))).toBeNull(); + expect(getPixPayloadInfo(buildPayloadWithMerchantName(""))).toBeNull(); }); test("when the merchant city is present but empty", () => { - expect(parsePixPayload(buildPayloadWithMerchantCity(""))).toBeNull(); + expect(getPixPayloadInfo(buildPayloadWithMerchantCity(""))).toBeNull(); }); }); describe("should parse a static payload", () => { test("should ignore the transaction amount and the txid of a dynamic payload, which belong to the PSP location", () => { expect( - parsePixPayload( + getPixPayloadInfo( "00020101021226480014br.gov.bcb.pix2526pix.example.com/qr/v2/123452040000530398654041.005802BR5901A6001B62100506ABC1236304C7F9", ), ).toEqual({ @@ -319,7 +331,7 @@ describe("parsePixPayload", () => { }); test("should accept a transaction amount of zero in a dynamic payload, whose amount the PSP location settles", () => { - expect(parsePixPayload(buildDynamicPayloadWithAmount("0.00"))).toEqual({ + expect(getPixPayloadInfo(buildDynamicPayloadWithAmount("0.00"))).toEqual({ url: DYNAMIC_URL, merchantName: "Fulano de Tal", merchantCity: "BRASILIA", @@ -328,7 +340,7 @@ describe("parsePixPayload", () => { }); test("from the static QR Code example in the Bacen 'Manual de Padrões para Iniciação do Pix', with no key of its own for a field the payload does not carry", () => { - expect(parsePixPayload(BACEN_STATIC)).toStrictEqual({ + expect(getPixPayloadInfo(BACEN_STATIC)).toStrictEqual({ key: "123e4567-e12b-12d1-a456-426655440000", merchantName: "Fulano de Tal", merchantCity: "BRASILIA", @@ -337,7 +349,9 @@ describe("parsePixPayload", () => { }); test("for a Pix Saque BR Code, reading back the fss (26-03) of §2.6 with a transaction amount of zero", () => { - expect(parsePixPayload(buildWithdrawalPayload(WITHDRAWAL_FACILITATOR_ISPB, "0.00"))).toEqual({ + expect( + getPixPayloadInfo(buildWithdrawalPayload(WITHDRAWAL_FACILITATOR_ISPB, "0.00")), + ).toEqual({ key: "12345678909", withdrawalFacilitator: "12345678", merchantName: "Fulano de Tal", @@ -348,7 +362,7 @@ describe("parsePixPayload", () => { }); test("for a Pix Saque BR Code whose amount is written as the plain '0' of the BR Code field table", () => { - expect(parsePixPayload(buildWithdrawalPayload(WITHDRAWAL_FACILITATOR_ISPB, "0"))).toEqual({ + expect(getPixPayloadInfo(buildWithdrawalPayload(WITHDRAWAL_FACILITATOR_ISPB, "0"))).toEqual({ key: "12345678909", withdrawalFacilitator: "12345678", merchantName: "Fulano de Tal", @@ -359,7 +373,7 @@ describe("parsePixPayload", () => { }); test("for a Pix Saque BR Code that states no transaction amount at all", () => { - expect(parsePixPayload(buildWithdrawalPayload(WITHDRAWAL_FACILITATOR_ISPB))).toEqual({ + expect(getPixPayloadInfo(buildWithdrawalPayload(WITHDRAWAL_FACILITATOR_ISPB))).toEqual({ key: "12345678909", withdrawalFacilitator: "12345678", merchantName: "Fulano de Tal", @@ -370,7 +384,7 @@ describe("parsePixPayload", () => { test("marked single use by the point of initiation method 12, which the manual allows on any BR Code", () => { expect(hasValidCrc(KEY_MARKED_SINGLE_USE)).toBe(true); - expect(parsePixPayload(KEY_MARKED_SINGLE_USE)).toEqual({ + expect(getPixPayloadInfo(KEY_MARKED_SINGLE_USE)).toEqual({ key: "12345678909", merchantName: "Fulano de Tal", merchantCity: "BRASILIA", @@ -379,11 +393,11 @@ describe("parsePixPayload", () => { }); test("dropping the *** placeholder of an absent txid", () => { - expect(parsePixPayload(BACEN_STATIC)).not.toHaveProperty("txid"); + expect(getPixPayloadInfo(BACEN_STATIC)).not.toHaveProperty("txid"); }); test("with an amount and a txid, as in a widely published community example", () => { - expect(parsePixPayload(COMMUNITY_STATIC)).toEqual({ + expect(getPixPayloadInfo(COMMUNITY_STATIC)).toEqual({ key: "bee05743-4291-4f3c-9259-595df1307ba1", merchantName: "Alexandre Lima", merchantCity: "Presidente Prudente", @@ -394,7 +408,7 @@ describe("parsePixPayload", () => { }); test("picking the Pix arrangement out of the multi-arrangement payload from the 'Manual do BR Code' §2.2", () => { - expect(parsePixPayload(BRCODE_MANUAL)).toEqual({ + expect(getPixPayloadInfo(BRCODE_MANUAL)).toEqual({ key: "123e4567-e12b-12d1-a456-426655440000", merchantName: "NOME DO RECEBEDOR", merchantCity: "BRASILIA", @@ -405,7 +419,7 @@ describe("parsePixPayload", () => { }); test("when the merchant account information sits at the last valid id (51), not just at the usual 26", () => { - expect(parsePixPayload(buildPayloadWithMerchantAccountInformationTag("51"))).toEqual({ + expect(getPixPayloadInfo(buildPayloadWithMerchantAccountInformationTag("51"))).toEqual({ key: "12345678909", merchantName: "Fulano de Tal", merchantCity: "BRASILIA", @@ -414,25 +428,25 @@ describe("parsePixPayload", () => { }); test("accepting a transaction amount whose length is exactly 13 characters", () => { - expect(parsePixPayload(buildPayloadWithAmount("9999999999.99"))?.amount).toBe( + expect(getPixPayloadInfo(buildPayloadWithAmount("9999999999.99"))?.amount).toBe( 9_999_999_999.99, ); }); test("accepting a transaction amount written as a whole number, with no decimal point", () => { - expect(parsePixPayload(buildPayloadWithAmount("100"))?.amount).toBe(100); + expect(getPixPayloadInfo(buildPayloadWithAmount("100"))?.amount).toBe(100); }); test("without a key property when the payload is dynamic (carries a url instead)", () => { - expect(parsePixPayload(BACEN_DYNAMIC)).not.toHaveProperty("key"); + expect(getPixPayloadInfo(BACEN_DYNAMIC)).not.toHaveProperty("key"); }); test("without a url property when the payload is static (carries a key instead)", () => { - expect(parsePixPayload(BACEN_STATIC)).not.toHaveProperty("url"); + expect(getPixPayloadInfo(BACEN_STATIC)).not.toHaveProperty("url"); }); test("without a txid property when the payload carries no additional data template at all", () => { - expect(parsePixPayload(buildPayload(MERCHANT_ACCOUNT_INFORMATION))).not.toHaveProperty( + expect(getPixPayloadInfo(buildPayload(MERCHANT_ACCOUNT_INFORMATION))).not.toHaveProperty( "txid", ); }); @@ -445,7 +459,7 @@ describe("parsePixPayload", () => { description: "Pedido 42", }); - expect(parsePixPayload(payload ?? "")).toEqual({ + expect(getPixPayloadInfo(payload ?? "")).toEqual({ key: "12345678909", description: "Pedido 42", merchantName: "Fulano de Tal", @@ -457,7 +471,7 @@ describe("parsePixPayload", () => { describe("should parse a dynamic payload", () => { test("from the dynamic QR Code example in the Bacen 'Manual de Padrões para Iniciação do Pix'", () => { - expect(parsePixPayload(BACEN_DYNAMIC)).toEqual({ + expect(getPixPayloadInfo(BACEN_DYNAMIC)).toEqual({ url: "pix.example.com/8b3da2f39a4140d1a91abd93113bd441", merchantName: "Fulano de Tal", merchantCity: "BRASILIA", @@ -466,7 +480,7 @@ describe("parsePixPayload", () => { }); test("picking the Pix arrangement out of the composite QR Code example in the Bacen manual", () => { - expect(parsePixPayload(BACEN_COMPOSITE)).toEqual({ + expect(getPixPayloadInfo(BACEN_COMPOSITE)).toEqual({ url: "pix.example.com/8b3da2f39a4140d1a91abd93113bd441", merchantName: "Fulano de Tal", merchantCity: "BRASILIA", @@ -475,12 +489,12 @@ describe("parsePixPayload", () => { }); test("reading the point of initiation method 11 as static, per the Bacen static example with it made explicit", () => { - expect(parsePixPayload(STATIC_POINT_OF_INITIATION)?.pointOfInitiation).toBe("static"); + expect(getPixPayloadInfo(STATIC_POINT_OF_INITIATION)?.pointOfInitiation).toBe("static"); }); test("when it carries no point of initiation method at all, which the manual marks optional", () => { expect(hasValidCrc(URL_WITHOUT_POINT_OF_INITIATION)).toBe(true); - expect(parsePixPayload(URL_WITHOUT_POINT_OF_INITIATION)).toEqual({ + expect(getPixPayloadInfo(URL_WITHOUT_POINT_OF_INITIATION)).toEqual({ url: "pix.example.com/qr/v2/1234", merchantName: "Fulano de Tal", merchantCity: "BRASILIA", @@ -490,7 +504,7 @@ describe("parsePixPayload", () => { test("when the point of initiation method is 11, since the PSP location is what makes it dynamic", () => { expect(hasValidCrc(URL_WITH_STATIC_POINT_OF_INITIATION)).toBe(true); - expect(parsePixPayload(URL_WITH_STATIC_POINT_OF_INITIATION)).toEqual({ + expect(getPixPayloadInfo(URL_WITH_STATIC_POINT_OF_INITIATION)).toEqual({ url: "pix.example.com/qr/v2/1234", merchantName: "Fulano de Tal", merchantCity: "BRASILIA", @@ -510,7 +524,7 @@ describe("parsePixPayload", () => { txid: "RP123456782019", }; - expect(parsePixPayload(generatePixPayload(pix) ?? "")).toEqual({ + expect(getPixPayloadInfo(generatePixPayload(pix) ?? "")).toEqual({ ...pix, pointOfInitiation: "static", }); @@ -526,7 +540,7 @@ describe("parsePixPayload", () => { txid: `TX${index}`, }; - expect(parsePixPayload(generatePixPayload(pix) ?? "")).toEqual({ + expect(getPixPayloadInfo(generatePixPayload(pix) ?? "")).toEqual({ ...pix, pointOfInitiation: "static", }); @@ -541,7 +555,7 @@ describe("parsePixPayload", () => { fc.assert( fc.property(names, fc.uuid(), (merchantName, key) => { const payload = generatePixPayload({ key, merchantName, merchantCity: "BRASILIA" }); - const parsed = parsePixPayload(payload ?? ""); + const parsed = getPixPayloadInfo(payload ?? ""); expect(parsed?.merchantName).toBe(merchantName); expect(parsed?.merchantCity).toBe("BRASILIA"); @@ -559,7 +573,7 @@ describe("parsePixPayload", () => { const replacement = crc.charAt(index) === "0" ? "1" : "0"; const broken = `${(payload ?? "").slice(0, -4)}${crc.slice(0, index)}${replacement}${crc.slice(index + 1)}`; - expect(parsePixPayload(broken)).toBeNull(); + expect(getPixPayloadInfo(broken)).toBeNull(); }), ); }); @@ -567,7 +581,7 @@ describe("parsePixPayload", () => { test("should never throw and always return a BR Code or null", () => { fc.assert( fc.property(fc.anything(), (value) => { - const parsed = parsePixPayload(value as string); + const parsed = getPixPayloadInfo(value as string); expect(parsed === null || typeof parsed.merchantName === "string").toBe(true); }), @@ -576,14 +590,14 @@ describe("parsePixPayload", () => { }); }); -describe("parsePixPayload types", () => { +describe("getPixPayloadInfo types", () => { test("should take a string and return a Pix payload or null", () => { - expectTypeOf(parsePixPayload).parameter(0).toEqualTypeOf(); - expectTypeOf(parsePixPayload).returns.toEqualTypeOf(); + expectTypeOf(getPixPayloadInfo).parameter(0).toEqualTypeOf(); + expectTypeOf(getPixPayloadInfo).returns.toEqualTypeOf(); }); test("should restrict the Pix payload shape and its point of initiation", () => { - expectTypeOf().toEqualTypeOf<{ + expectTypeOf().toEqualTypeOf<{ key?: string; url?: string; description?: string; diff --git a/src/parse-pix-payload/parse-pix-payload.ts b/src/get-pix-payload-info/get-pix-payload-info.ts similarity index 87% rename from src/parse-pix-payload/parse-pix-payload.ts rename to src/get-pix-payload-info/get-pix-payload-info.ts index 477c0d5a..4383d280 100644 --- a/src/parse-pix-payload/parse-pix-payload.ts +++ b/src/get-pix-payload-info/get-pix-payload-info.ts @@ -38,8 +38,8 @@ import { type TlvFields, parseTlv } from "../_internals/parse-tlv/parse-tlv"; */ export type PixPointOfInitiation = "static" | "dynamic"; -/** The fields `parsePixPayload` reads out of a Pix BR Code. */ -export type PixPayload = { +/** The fields `getPixPayloadInfo` reads out of a Pix BR Code. */ +export type PixPayloadInfo = { /** The Pix key of the receiver, present in a static payload. */ key?: string; /** URL of the dynamic payload, present instead of `key` in a dynamic one. */ @@ -146,6 +146,7 @@ const resolveMerchantKeyInfo = (fields: TlvFields): MerchantKeyInfo | null => { if ((key === undefined) === (url === undefined)) return null; if (key !== undefined && !key) return null; if (url !== undefined && !isValidPixUrl(url)) return null; + if (withdrawalFacilitator !== undefined && url !== undefined) return null; if ( withdrawalFacilitator !== undefined && !WITHDRAWAL_FACILITATOR_REGEX.test(withdrawalFacilitator) @@ -178,11 +179,11 @@ const buildPixPayload = ( merchantName: string, merchantCity: string, optional: OptionalPixFields, -): PixPayload => { +): PixPayloadInfo => { const { key, url, description, withdrawalFacilitator, amount, txid, pointOfInitiation } = optional; const isDynamic = url !== undefined || pointOfInitiation === PIX_DYNAMIC_POINT_OF_INITIATION; - const pix: PixPayload = { + const pix: PixPayloadInfo = { merchantName, merchantCity, pointOfInitiation: isDynamic ? "dynamic" : "static", @@ -216,8 +217,12 @@ const buildPixPayload = ( * overrun them, so they are not enforced here, and neither is the 77 character limit of the * Pix key field (26-01). * - * Payloads that carry the location in an Unreserved Template (IDs 80 to 99), as the "QR Code - * composto" of Pix Automático (Pix recorrente) does, are out of scope and rejected. + * Unreserved Templates (IDs 80 to 99) are ignored. The "QR Code composto" of Pix Automático + * (Pix recorrente) writes its recurrence location in one of them: when such a payload also + * carries a payment location in 26-25, as the composite example of the Pix manual does, it is + * parsed here as an ordinary dynamic payload and its recurrence location is dropped, so a + * consumer that has to tell the two apart cannot rely on this parser. Only a payload with no + * Pix template at all in IDs 26 to 51 returns `null`. * * The merchant account information must carry exactly one of a Pix key (26-01) or a PSP * location (26-25); the location is checked with the same host and path rule @@ -237,15 +242,19 @@ const buildPixPayload = ( * válido […] indica que esse é um QR Code para Pix Saque", whose amount is settled at payment * time. So `54` set to `"0"` or `"0.00"` is accepted together with `fss` and rejected without * it; that rejection is a deliberate restriction of this library, not a rule of the manual, - * whose field table allows `"0"` in any payload. A `fss` that is not 8 digits is rejected. + * whose field table allows `"0"` in any payload. A `fss` that is not 8 digits is rejected, and + * so is a `fss` written next to a PSP location: §2.7 of the Manual de Padrões para Iniciação do + * Pix maps the dynamic QR Code to exactly two sub-objects, `00` (GUI) and `25` (URL), while + * `fss` belongs to the static template of §2.6, whose §2.6.1 states that "não há funcionalidade + * de Pix Troco para QR Codes estáticos, apenas para QR Codes dinâmicos". * * @param {string} value - The BR Code payload to be parsed. - * @returns {PixPayload|null} The Pix data of the payload, or `null` when it is not a valid Pix + * @returns {PixPayloadInfo|null} The Pix data of the payload, or `null` when it is not a valid Pix * BR Code. * * @example * ```typescript - * parsePixPayload( + * getPixPayloadInfo( * "00020126580014br.gov.bcb.pix0136123e4567-e12b-12d1-a456-426655440000" + * "5204000053039865802BR5913Fulano de Tal6008BRASILIA62070503***63041D3D", * ); @@ -259,10 +268,12 @@ const buildPixPayload = ( * * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/spb_docs/ManualBRCode.pdf * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/pix/Regulamento_Pix/II_ManualdePadroesparaIniciacaodoPix.pdf - * @see Official: https://github.com/bacen/pix-api Pix (SPI) OpenAPI spec. - * @see Official: https://github.com/bacen/pix-dict-api DICT OpenAPI spec. + * @see Official: https://github.com/bacen/pix-api + * Pix (SPI) OpenAPI spec. + * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/pix/API-DICT.html + * DICT (Diretório de Identificadores de Contas Transacionais) API specification. */ -export const parsePixPayload = (value: string): PixPayload | null => { +export const getPixPayloadInfo = (value: string): PixPayloadInfo | null => { if (typeof value !== "string") return null; const payload = value.trim(); diff --git a/src/get-state-by-ibge-code/get-state-by-ibge-code.ts b/src/get-state-by-ibge-code/get-state-by-ibge-code.ts index 42d30a4e..266b41d9 100644 --- a/src/get-state-by-ibge-code/get-state-by-ibge-code.ts +++ b/src/get-state-by-ibge-code/get-state-by-ibge-code.ts @@ -2,12 +2,15 @@ import { DATA, type State } from "../_internals/constants/states"; import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +export type { State } from "../_internals/constants/states"; + /** * Retrieves the Brazilian state whose 2-digit IBGE code ("cUF", the Código da Unidade da * Federação) matches the given value. * * The IBGE code is the same 2-digit UF code found in the first field of every DF-e access key - * (chave de acesso) issued for NF-e, NFC-e, CT-e and MDF-e documents. + * (chave de acesso) issued for any of the models `isValidNfeKey` covers: NF-e (55), NFC-e + * (65), CT-e (57), MDF-e (58), CT-e OS (67), GTV-e (64), BP-e (63), NF3e (66) and NFCom (62). * * A `code` given as a number must be a non-negative integer: a sign and a decimal point are * not digits, so `-35` and `3.5` are rejected instead of being read as `35`. @@ -17,7 +20,8 @@ import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-d * @returns {State|null} The matching `State` object, or `null` when `code` is not a known * IBGE UF code. * - * @see Official: https://servicodados.ibge.gov.br/api/v1/localidades/estados (IBGE Localidades API, field `id`) + * @see Official: https://servicodados.ibge.gov.br/api/v1/localidades/estados + * (IBGE Localidades API, field `id`) * @see Official: https://www.confaz.fazenda.gov.br/legislacao/arquivo-manuais/moc7-visao-geral.pdf * (Manual de Orientação do Contribuinte, "chave de acesso" / "Tabela do IBGE") * diff --git a/src/get-state-code-by-name/get-state-code-by-name.test.ts b/src/get-state-code-by-name/get-state-code-by-name.test.ts index 01c34c1d..f84096a7 100644 --- a/src/get-state-code-by-name/get-state-code-by-name.test.ts +++ b/src/get-state-code-by-name/get-state-code-by-name.test.ts @@ -23,6 +23,12 @@ describe("getStateCodeByName", () => { expect(getStateCodeByName(" São Paulo ")).toBe("SP"); }); + it("should collapse every run of internal whitespace", () => { + expect(getStateCodeByName("Rio de Janeiro")).toBe("RJ"); + expect(getStateCodeByName("Rio\tde\nJaneiro")).toBe("RJ"); + expect(getStateCodeByName(" sao paulo ")).toBe("SP"); + }); + it("should combine accent removal, casing and trimming together", () => { expect(getStateCodeByName(" sao PAULO ")).toBe("SP"); }); @@ -40,6 +46,16 @@ describe("getStateCodeByName", () => { expect(getStateCodeByName("Rio Grande do Sul")).toBe("RS"); }); + it("should not match a name written without the space the published one carries", () => { + expect(getStateCodeByName("sao paulo")).toBe("SP"); + expect(getStateCodeByName("saopaulo")).toBeNull(); + }); + + it("should fold the casing to lower case, which leaves ß as it is instead of expanding it to ss", () => { + expect(getStateCodeByName("Mato Grosso")).toBe("MT"); + expect(getStateCodeByName("Mato Großo")).toBeNull(); + }); + it("should return null for a name that matches no state", () => { expect(getStateCodeByName("Neverland")).toBeNull(); }); diff --git a/src/get-state-code-by-name/get-state-code-by-name.ts b/src/get-state-code-by-name/get-state-code-by-name.ts index 9c746f92..28fc41a6 100644 --- a/src/get-state-code-by-name/get-state-code-by-name.ts +++ b/src/get-state-code-by-name/get-state-code-by-name.ts @@ -1,30 +1,42 @@ import { DATA, type StateCode } from "../_internals/constants/states"; import { removeAccents } from "../remove-accents/remove-accents"; +export type { StateCode } from "../_internals/constants/states"; + +const normalizeName = (value: string): string => + removeAccents(value).replaceAll(/\s+/g, " ").trim().toLowerCase(); + /** * Retrieves the two-letter code (sigla) of a Brazilian state given its full name. * * The match is accent-insensitive, case-insensitive and ignores leading/trailing whitespace, - * so `" são paulo "`, `"Sao Paulo"` and `"SÃO PAULO"` all resolve to `"SP"`. + * so `" são paulo "`, `"Sao Paulo"` and `"SÃO PAULO"` all resolve to `"SP"`. Every run of + * internal whitespace collapses into a single space too, so `"Rio de Janeiro"` resolves to + * `"RJ"`, while a name written without the space matches nothing: only the runs that are there + * collapse, so `"saopaulo"` is not `"São Paulo"`. The casing is folded to lower case, the + * direction that leaves `"ß"` alone instead of expanding it into `"SS"`, so `"Mato Großo"` is + * not `"Mato Grosso"` either. * * @param {string} name - The full name of the state. * @returns {StateCode|null} The two-letter state code, or `null` when `name` does not match * any Brazilian state. * - * @see Official: https://servicodados.ibge.gov.br/api/v1/localidades/estados (IBGE Localidades API) + * @see Official: https://servicodados.ibge.gov.br/api/v1/localidades/estados + * (IBGE Localidades API) * * @example * ```typescript * getStateCodeByName("São Paulo"); // "SP" * getStateCodeByName("sao paulo"); // "SP" * getStateCodeByName(" Rio de Janeiro "); // "RJ" + * getStateCodeByName("Rio de Janeiro"); // "RJ" * getStateCodeByName("Neverland"); // null * ``` */ export const getStateCodeByName = (name: string): StateCode | null => { - const normalized = removeAccents(name).trim().toLowerCase(); + const normalized = normalizeName(name); - const state = DATA.find((entry) => removeAccents(entry.name).toLowerCase() === normalized); + const state = DATA.find((entry) => normalizeName(entry.name) === normalized); return state ? state.code : null; }; diff --git a/src/get-state-name-by-code/get-state-name-by-code.ts b/src/get-state-name-by-code/get-state-name-by-code.ts index 6bf60631..a9eda4ac 100644 --- a/src/get-state-name-by-code/get-state-name-by-code.ts +++ b/src/get-state-name-by-code/get-state-name-by-code.ts @@ -1,5 +1,7 @@ import { DATA, type StateName } from "../_internals/constants/states"; +export type { StateName } from "../_internals/constants/states"; + /** * Retrieves the full name of a Brazilian state given its two-letter code (sigla). * @@ -10,7 +12,8 @@ import { DATA, type StateName } from "../_internals/constants/states"; * @returns {StateName|null} The full state name, or `null` when `code` does not match any * Brazilian state. * - * @see Official: https://servicodados.ibge.gov.br/api/v1/localidades/estados (IBGE Localidades API) + * @see Official: https://servicodados.ibge.gov.br/api/v1/localidades/estados + * (IBGE Localidades API) * * @example * ```typescript diff --git a/src/get-states/get-states.ts b/src/get-states/get-states.ts index 3125fdc5..460e2876 100644 --- a/src/get-states/get-states.ts +++ b/src/get-states/get-states.ts @@ -1,5 +1,7 @@ import { DATA, type State } from "../_internals/constants/states"; +export type { State } from "../_internals/constants/states"; + /** * Retrieves a list of all Brazilian states with their codes and names. * diff --git a/src/get-timezone-by-state/constants.ts b/src/get-timezone-by-state/constants.ts index 90bdf88d..99291586 100644 --- a/src/get-timezone-by-state/constants.ts +++ b/src/get-timezone-by-state/constants.ts @@ -10,9 +10,11 @@ * UTC-02:00 offset is out of scope here. * * @see Official: https://www.iana.org/time-zones - * @see Based on: https://raw.githubusercontent.com/eggert/tz/main/zone1970.tab (IANA tz + * @see Based on: https://raw.githubusercontent.com/eggert/tz/main/zone1970.tab + * (IANA tz * database data file, `BR` rows) - * @see Based on: https://en.wikipedia.org/wiki/Time_in_Brazil Used to confirm the state + * @see Based on: https://en.wikipedia.org/wiki/Time_in_Brazil + * Used to confirm the state * coverage of each zone. */ export const STATE_TIMEZONES: Record = { diff --git a/src/get-timezone-by-state/get-timezone-by-state.ts b/src/get-timezone-by-state/get-timezone-by-state.ts index c9c3d5b2..5e31a910 100644 --- a/src/get-timezone-by-state/get-timezone-by-state.ts +++ b/src/get-timezone-by-state/get-timezone-by-state.ts @@ -18,9 +18,11 @@ import { STATE_TIMEZONES } from "./constants"; * any Brazilian state. * * @see Official: https://www.iana.org/time-zones - * @see Based on: https://raw.githubusercontent.com/eggert/tz/main/zone1970.tab (IANA tz + * @see Based on: https://raw.githubusercontent.com/eggert/tz/main/zone1970.tab + * (IANA tz * database data file, `BR` rows) - * @see Based on: https://en.wikipedia.org/wiki/Time_in_Brazil Used to confirm the state + * @see Based on: https://en.wikipedia.org/wiki/Time_in_Brazil + * Used to confirm the state * coverage of each zone. * * @example diff --git a/src/index.test.ts b/src/index.test.ts index f67d90aa..c3d27a22 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,40 +1,43 @@ import { describe, expect, test } from "./_internals/test/runtime"; import { - type AddBusinessDaysParams, type AddressInfo, type AreaCodeInfo, type Bank, type BoletoInfo, + type BusinessDayOptions, type CapitalizeOptions, type Cbo, type CepAddressInfo, type CepProvider, - type Certidao, + type CertidaoInfo, type CertidaoType, type Cfop, type Cnae, - type ConvertCurrencyToWordsOptions, type ConvertDateToWordsOptions, type ConvertNumberToWordsOptions, - type DifferenceInBusinessDaysParams, type FormatBoletoOptions, type FormatCaepfOptions, type FormatCeiOptions, type FormatCepOptions, type FormatCertidaoOptions, + type FormatCnaeOptions, + type FormatLegalNatureOptions, type FormatCnhOptions, type FormatCnoOptions, type FormatCnpjOptions, type FormatCnsOptions, type FormatCpfOptions, type FormatCurrencyOptions, + type FormatNcmOptions, + type FormatNfeKeyOptions, type FormatPhoneOptions, type FormatPisOptions, type FormatProcessoJuridicoOptions, type GenerateBoletoOptions, + type GenerateCnpjOptions, type GenerateLicensePlateFormat, type GeneratePhoneType, - type GeneratePixPayloadParams, + type GeneratePixPayloadOptions, type GenerateProcessoJuridicoOptions, type GetAddressInfoByCepOptions, type GetBoletoInfoOptions, @@ -45,8 +48,7 @@ import { type GetMunicipalityOptions, type Holiday, type HolidayType, - type Iban, - type IsBusinessDayOptions, + type IbanInfo, type IsHolidayOptions, type IsValidBankAccountOptions, type IsValidBankAccountParams, @@ -58,9 +60,10 @@ import { type IsValidPixKeyOptions, type IsValidRegistroProfissionalOptions, type LegalNature, + type LegalNatureCategory, type LicensePlateFormat, type Municipality, - type NfeKey, + type NfeKeyInfo, type NfeKeyModel, type NumberToWordsGender, type ParseCnpjOptions, @@ -68,15 +71,14 @@ import { type PhoneMask, type PhoneType, type PhoneVersion, - type PixKey, + type PixKeyInfo, type PixKeyType, - type PixPayload, + type PixPayloadInfo, type PixPointOfInitiation, type RegistroProfissionalCouncil, type State, type StateCode, type StateName, - type WordsCase, } from "./index"; import * as brazilianUtils from "./index"; @@ -134,6 +136,7 @@ const PUBLIC = [ "generatePis", "generatePixPayload", "generateProcessoJuridico", + "generateRenavam", "generateVoterId", "getAddressInfoByCep", "getAreaCodeInfo", @@ -144,16 +147,22 @@ const PUBLIC = [ "getBoletoInfo", "getCbo", "getCepInfoByAddress", + "getCertidaoInfo", "getCfop", "getCities", "getCnae", "getFormatLicensePlate", "getHolidays", + "getIbanInfo", "getLegalNature", "getLegalNatures", + "getLegalNaturesByCategory", "getMunicipalities", "getMunicipality", "getMunicipalityByCode", + "getNfeKeyInfo", + "getPixKeyInfo", + "getPixPayloadInfo", "getStateByIbgeCode", "getStateCodeByName", "getStateNameByCode", @@ -204,24 +213,31 @@ const PUBLIC = [ "isValidVin", "isValidVoterId", "parseBoleto", + "parseCaepf", + "parseCbo", + "parseCei", "parseCep", "parseCertidao", + "parseCfop", + "parseCnae", "parseCnh", + "parseCno", "parseCnpj", + "parseCns", "parseCpf", "parseCurrency", "parseIban", "parseLegalNature", "parseLicensePlate", + "parseNcm", "parseNfeKey", "parsePassport", "parsePhone", "parsePis", - "parsePixKey", - "parsePixPayload", "parseProcessoJuridico", "parseVoterId", "removeAccents", + "subBusinessDays", ].sort(); const NETWORK_ENTRY_POINTS = new Set(["getAddressInfoByCep", "getCepInfoByAddress"]); @@ -251,41 +267,44 @@ describe("Public API", () => { test("should export every documented public type", () => { const publicTypes: Partial<{ - AddBusinessDaysParams: AddBusinessDaysParams; AddressInfo: AddressInfo; AreaCodeInfo: AreaCodeInfo; Bank: Bank; BoletoInfo: BoletoInfo; + BusinessDayOptions: BusinessDayOptions; CapitalizeOptions: CapitalizeOptions; Cbo: Cbo; CepAddressInfo: CepAddressInfo; CepProvider: CepProvider; - Certidao: Certidao; + CertidaoInfo: CertidaoInfo; CertidaoType: CertidaoType; Cfop: Cfop; Cnae: Cnae; - ConvertCurrencyToWordsOptions: ConvertCurrencyToWordsOptions; ConvertDateToWordsOptions: ConvertDateToWordsOptions; ConvertNumberToWordsOptions: ConvertNumberToWordsOptions; - DifferenceInBusinessDaysParams: DifferenceInBusinessDaysParams; FormatBoletoOptions: FormatBoletoOptions; FormatCaepfOptions: FormatCaepfOptions; FormatCeiOptions: FormatCeiOptions; FormatCepOptions: FormatCepOptions; FormatCertidaoOptions: FormatCertidaoOptions; + FormatCnaeOptions: FormatCnaeOptions; + FormatLegalNatureOptions: FormatLegalNatureOptions; FormatCnhOptions: FormatCnhOptions; FormatCnoOptions: FormatCnoOptions; FormatCnpjOptions: FormatCnpjOptions; FormatCnsOptions: FormatCnsOptions; FormatCpfOptions: FormatCpfOptions; FormatCurrencyOptions: FormatCurrencyOptions; + FormatNcmOptions: FormatNcmOptions; + FormatNfeKeyOptions: FormatNfeKeyOptions; FormatPhoneOptions: FormatPhoneOptions; FormatPisOptions: FormatPisOptions; FormatProcessoJuridicoOptions: FormatProcessoJuridicoOptions; GenerateBoletoOptions: GenerateBoletoOptions; + GenerateCnpjOptions: GenerateCnpjOptions; GenerateLicensePlateFormat: GenerateLicensePlateFormat; GeneratePhoneType: GeneratePhoneType; - GeneratePixPayloadParams: GeneratePixPayloadParams; + GeneratePixPayloadOptions: GeneratePixPayloadOptions; GenerateProcessoJuridicoOptions: GenerateProcessoJuridicoOptions; GetAddressInfoByCepOptions: GetAddressInfoByCepOptions; GetBoletoInfoOptions: GetBoletoInfoOptions; @@ -296,8 +315,7 @@ describe("Public API", () => { GetMunicipalityOptions: GetMunicipalityOptions; Holiday: Holiday; HolidayType: HolidayType; - Iban: Iban; - IsBusinessDayOptions: IsBusinessDayOptions; + IbanInfo: IbanInfo; IsHolidayOptions: IsHolidayOptions; IsValidBankAccountOptions: IsValidBankAccountOptions; IsValidBankAccountParams: IsValidBankAccountParams; @@ -309,9 +327,10 @@ describe("Public API", () => { IsValidPixKeyOptions: IsValidPixKeyOptions; IsValidRegistroProfissionalOptions: IsValidRegistroProfissionalOptions; LegalNature: LegalNature; + LegalNatureCategory: LegalNatureCategory; LicensePlateFormat: LicensePlateFormat; Municipality: Municipality; - NfeKey: NfeKey; + NfeKeyInfo: NfeKeyInfo; NfeKeyModel: NfeKeyModel; NumberToWordsGender: NumberToWordsGender; ParseCnpjOptions: ParseCnpjOptions; @@ -319,15 +338,14 @@ describe("Public API", () => { PhoneMask: PhoneMask; PhoneType: PhoneType; PhoneVersion: PhoneVersion; - PixKey: PixKey; + PixKeyInfo: PixKeyInfo; PixKeyType: PixKeyType; - PixPayload: PixPayload; + PixPayloadInfo: PixPayloadInfo; PixPointOfInitiation: PixPointOfInitiation; RegistroProfissionalCouncil: RegistroProfissionalCouncil; State: State; StateCode: StateCode; StateName: StateName; - WordsCase: WordsCase; }> = {}; expect(publicTypes).toEqual({}); diff --git a/src/index.ts b/src/index.ts index 1729cb11..30921d45 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,21 @@ +import { formatCep } from "./format-cep/format-cep"; +import { formatCnpj } from "./format-cnpj/format-cnpj"; +import { formatCpf } from "./format-cpf/format-cpf"; +import { generateCnpj } from "./generate-cnpj/generate-cnpj"; +import { generateCpf } from "./generate-cpf/generate-cpf"; +import { isValidCep } from "./is-valid-cep/is-valid-cep"; +import { isValidCnpj } from "./is-valid-cnpj/is-valid-cnpj"; +import { isValidCpf } from "./is-valid-cpf/is-valid-cpf"; +import { isValidIe } from "./is-valid-ie/is-valid-ie"; +import { isValidPis } from "./is-valid-pis/is-valid-pis"; + export type { Bank } from "./_internals/constants/banks"; export type { Municipality } from "./_internals/constants/cities"; export type { State, StateCode, StateName } from "./_internals/constants/states"; -export type { NumberToWordsGender, WordsCase } from "./_internals/number-to-words/number-to-words"; -export { type AddBusinessDaysParams, addBusinessDays } from "./add-business-days/add-business-days"; +export type { NumberToWordsGender } from "./_internals/number-to-words/number-to-words"; +export { addBusinessDays } from "./add-business-days/add-business-days"; export { type CapitalizeOptions, capitalize } from "./capitalize/capitalize"; -export { - type ConvertCurrencyToWordsOptions, - convertCurrencyToWords, -} from "./convert-currency-to-words/convert-currency-to-words"; +export { convertCurrencyToWords } from "./convert-currency-to-words/convert-currency-to-words"; export { type ConvertDateToWordsOptions, convertDateToWords, @@ -17,16 +25,13 @@ export { type ConvertNumberToWordsOptions, convertNumberToWords, } from "./convert-number-to-words/convert-number-to-words"; -export { - type DifferenceInBusinessDaysParams, - differenceInBusinessDays, -} from "./difference-in-business-days/difference-in-business-days"; +export { differenceInBusinessDays } from "./difference-in-business-days/difference-in-business-days"; export { type FormatBoletoOptions, formatBoleto } from "./format-boleto/format-boleto"; export { type FormatCaepfOptions, formatCaepf } from "./format-caepf/format-caepf"; export { type FormatCeiOptions, formatCei } from "./format-cei/format-cei"; export { type FormatCepOptions, formatCep } from "./format-cep/format-cep"; export { type FormatCertidaoOptions, formatCertidao } from "./format-certidao/format-certidao"; -export { formatCnae } from "./format-cnae/format-cnae"; +export { type FormatCnaeOptions, formatCnae } from "./format-cnae/format-cnae"; export { type FormatCnhOptions, formatCnh } from "./format-cnh/format-cnh"; export { type FormatCnoOptions, formatCno } from "./format-cno/format-cno"; export { type FormatCnpjOptions, formatCnpj } from "./format-cnpj/format-cnpj"; @@ -34,10 +39,13 @@ export { type FormatCnsOptions, formatCns } from "./format-cns/format-cns"; export { type FormatCpfOptions, formatCpf } from "./format-cpf/format-cpf"; export { type FormatCurrencyOptions, formatCurrency } from "./format-currency/format-currency"; export { formatIban } from "./format-iban/format-iban"; -export { formatLegalNature } from "./format-legal-nature/format-legal-nature"; +export { + type FormatLegalNatureOptions, + formatLegalNature, +} from "./format-legal-nature/format-legal-nature"; export { formatLicensePlate } from "./format-license-plate/format-license-plate"; -export { formatNcm } from "./format-ncm/format-ncm"; -export { formatNfeKey } from "./format-nfe-key/format-nfe-key"; +export { type FormatNcmOptions, formatNcm } from "./format-ncm/format-ncm"; +export { type FormatNfeKeyOptions, formatNfeKey } from "./format-nfe-key/format-nfe-key"; export { formatPassport } from "./format-passport/format-passport"; export { type FormatPhoneOptions, type PhoneMask, formatPhone } from "./format-phone/format-phone"; export { type FormatPisOptions, formatPis } from "./format-pis/format-pis"; @@ -49,7 +57,7 @@ export { formatVoterId } from "./format-voter-id/format-voter-id"; export { type GenerateBoletoOptions, generateBoleto } from "./generate-boleto/generate-boleto"; export { generateCep } from "./generate-cep/generate-cep"; export { generateCnh } from "./generate-cnh/generate-cnh"; -export { generateCnpj } from "./generate-cnpj/generate-cnpj"; +export { type GenerateCnpjOptions, generateCnpj } from "./generate-cnpj/generate-cnpj"; export { generateCpf } from "./generate-cpf/generate-cpf"; export { generateLegalNature } from "./generate-legal-nature/generate-legal-nature"; export { @@ -60,13 +68,14 @@ export { generatePassport } from "./generate-passport/generate-passport"; export { generatePhone, type GeneratePhoneType } from "./generate-phone/generate-phone"; export { generatePis } from "./generate-pis/generate-pis"; export { - type GeneratePixPayloadParams, + type GeneratePixPayloadOptions, generatePixPayload, } from "./generate-pix-payload/generate-pix-payload"; export { type GenerateProcessoJuridicoOptions, generateProcessoJuridico, } from "./generate-processo-juridico/generate-processo-juridico"; +export { generateRenavam } from "./generate-renavam/generate-renavam"; export { generateVoterId } from "./generate-voter-id/generate-voter-id"; export { type AddressInfo, @@ -97,6 +106,11 @@ export { GetCepInfoByAddressValidationError, getCepInfoByAddress, } from "./get-cep-info-by-address/get-cep-info-by-address"; +export { + type CertidaoInfo, + type CertidaoType, + getCertidaoInfo, +} from "./get-certidao-info/get-certidao-info"; export { type Cfop, getCfop } from "./get-cfop/get-cfop"; export { getCities } from "./get-cities/get-cities"; export { type Cnae, getCnae } from "./get-cnae/get-cnae"; @@ -110,8 +124,14 @@ export { type HolidayType, getHolidays, } from "./get-holidays/get-holidays"; -export { type LegalNature, getLegalNature } from "./get-legal-nature/get-legal-nature"; +export { type IbanInfo, getIbanInfo } from "./get-iban-info/get-iban-info"; +export { + type LegalNature, + type LegalNatureCategory, + getLegalNature, +} from "./get-legal-nature/get-legal-nature"; export { getLegalNatures } from "./get-legal-natures/get-legal-natures"; +export { getLegalNaturesByCategory } from "./get-legal-natures-by-category/get-legal-natures-by-category"; export { getMunicipalities } from "./get-municipalities/get-municipalities"; export { type GetMunicipalityByCodeOptions, @@ -120,12 +140,27 @@ export { getMunicipality, } from "./get-municipality/get-municipality"; export { getMunicipalityByCode } from "./get-municipality-by-code/get-municipality-by-code"; +export { + type NfeKeyInfo, + type NfeKeyModel, + getNfeKeyInfo, +} from "./get-nfe-key-info/get-nfe-key-info"; +export { + type PixKeyInfo, + type PixKeyType, + getPixKeyInfo, +} from "./get-pix-key-info/get-pix-key-info"; +export { + type PixPayloadInfo, + type PixPointOfInitiation, + getPixPayloadInfo, +} from "./get-pix-payload-info/get-pix-payload-info"; export { getStateByIbgeCode } from "./get-state-by-ibge-code/get-state-by-ibge-code"; export { getStateCodeByName } from "./get-state-code-by-name/get-state-code-by-name"; export { getStateNameByCode } from "./get-state-name-by-code/get-state-name-by-code"; export { getStates } from "./get-states/get-states"; export { getTimezoneByState } from "./get-timezone-by-state/get-timezone-by-state"; -export { type IsBusinessDayOptions, isBusinessDay } from "./is-business-day/is-business-day"; +export { type BusinessDayOptions, isBusinessDay } from "./is-business-day/is-business-day"; export { type IsHolidayOptions, isHoliday } from "./is-holiday/is-holiday"; export { type IsValidBankAccountOptions, @@ -183,28 +218,31 @@ export { isValidServicePhone } from "./is-valid-service-phone/is-valid-service-p export { isValidVin } from "./is-valid-vin/is-valid-vin"; export { isValidVoterId } from "./is-valid-voter-id/is-valid-voter-id"; export { parseBoleto } from "./parse-boleto/parse-boleto"; +export { parseCaepf } from "./parse-caepf/parse-caepf"; +export { parseCbo } from "./parse-cbo/parse-cbo"; +export { parseCei } from "./parse-cei/parse-cei"; export { parseCep } from "./parse-cep/parse-cep"; -export { type Certidao, type CertidaoType, parseCertidao } from "./parse-certidao/parse-certidao"; +export { parseCertidao } from "./parse-certidao/parse-certidao"; +export { parseCfop } from "./parse-cfop/parse-cfop"; +export { parseCnae } from "./parse-cnae/parse-cnae"; export { parseCnh } from "./parse-cnh/parse-cnh"; +export { parseCno } from "./parse-cno/parse-cno"; export { type ParseCnpjOptions, parseCnpj } from "./parse-cnpj/parse-cnpj"; +export { parseCns } from "./parse-cns/parse-cns"; export { parseCpf } from "./parse-cpf/parse-cpf"; export { type ParseCurrencyOptions, parseCurrency } from "./parse-currency/parse-currency"; -export { type Iban, parseIban } from "./parse-iban/parse-iban"; +export { parseIban } from "./parse-iban/parse-iban"; export { parseLegalNature } from "./parse-legal-nature/parse-legal-nature"; export { parseLicensePlate } from "./parse-license-plate/parse-license-plate"; -export { type NfeKey, type NfeKeyModel, parseNfeKey } from "./parse-nfe-key/parse-nfe-key"; +export { parseNcm } from "./parse-ncm/parse-ncm"; +export { parseNfeKey } from "./parse-nfe-key/parse-nfe-key"; export { parsePassport } from "./parse-passport/parse-passport"; export { parsePhone } from "./parse-phone/parse-phone"; export { parsePis } from "./parse-pis/parse-pis"; -export { type PixKey, type PixKeyType, parsePixKey } from "./parse-pix-key/parse-pix-key"; -export { - type PixPayload, - type PixPointOfInitiation, - parsePixPayload, -} from "./parse-pix-payload/parse-pix-payload"; export { parseProcessoJuridico } from "./parse-processo-juridico/parse-processo-juridico"; export { parseVoterId } from "./parse-voter-id/parse-voter-id"; export { removeAccents } from "./remove-accents/remove-accents"; +export { subBusinessDays } from "./sub-business-days/sub-business-days"; /** * The bank account `isValidBankAccount` checks: the bank, the agency and the account with its @@ -213,23 +251,68 @@ export { removeAccents } from "./remove-accents/remove-accents"; * @deprecated Use `IsValidBankAccountOptions` instead. */ export type { IsValidBankAccountParams } from "./is-valid-bank-account/is-valid-bank-account"; -/** @deprecated Use `formatCep` instead. */ -export { formatCep as formatCEP } from "./format-cep/format-cep"; -/** @deprecated Use `formatCnpj` instead. */ -export { formatCnpj as formatCNPJ } from "./format-cnpj/format-cnpj"; -/** @deprecated Use `formatCpf` instead. */ -export { formatCpf as formatCPF } from "./format-cpf/format-cpf"; -/** @deprecated Use `generateCnpj` instead. */ -export { generateCnpj as generateCNPJ } from "./generate-cnpj/generate-cnpj"; -/** @deprecated Use `generateCpf` instead. */ -export { generateCpf as generateCPF } from "./generate-cpf/generate-cpf"; -/** @deprecated Use `isValidCep` instead. */ -export { isValidCep as isValidCEP } from "./is-valid-cep/is-valid-cep"; -/** @deprecated Use `isValidCnpj` instead. */ -export { isValidCnpj as isValidCNPJ } from "./is-valid-cnpj/is-valid-cnpj"; -/** @deprecated Use `isValidCpf` instead. */ -export { isValidCpf as isValidCPF } from "./is-valid-cpf/is-valid-cpf"; -/** @deprecated Use `isValidIe` instead. */ -export { isValidIe as isValidIE } from "./is-valid-ie/is-valid-ie"; -/** @deprecated Use `isValidPis` instead. */ -export { isValidPis as isValidPIS } from "./is-valid-pis/is-valid-pis"; +// The deprecated aliases below are declared as constants rather than as renamed re-exports +// (`export { formatCpf as formatCPF }`) so that their `@deprecated` tag survives into the bundled +// declaration file: the bundler collapses every renamed re-export of the entry point into a single +// `export { ... }` statement, which carries no documentation, while a `declare const` keeps the +// comment written right above it. +/** + * Formats a CEP, the 1.x name of `formatCep`. + * + * @deprecated Use `formatCep` instead. + */ +export const formatCEP: typeof formatCep = formatCep; +/** + * Formats a CNPJ, the 1.x name of `formatCnpj`. + * + * @deprecated Use `formatCnpj` instead. + */ +export const formatCNPJ: typeof formatCnpj = formatCnpj; +/** + * Formats a CPF, the 1.x name of `formatCpf`. + * + * @deprecated Use `formatCpf` instead. + */ +export const formatCPF: typeof formatCpf = formatCpf; +/** + * Generates a valid random CNPJ, the 1.x name of `generateCnpj`. + * + * @deprecated Use `generateCnpj` instead. + */ +export const generateCNPJ: typeof generateCnpj = generateCnpj; +/** + * Generates a valid random CPF, the 1.x name of `generateCpf`. + * + * @deprecated Use `generateCpf` instead. + */ +export const generateCPF: typeof generateCpf = generateCpf; +/** + * Checks whether a CEP is valid, the 1.x name of `isValidCep`. + * + * @deprecated Use `isValidCep` instead. + */ +export const isValidCEP: typeof isValidCep = isValidCep; +/** + * Checks whether a CNPJ is valid, the 1.x name of `isValidCnpj`. + * + * @deprecated Use `isValidCnpj` instead. + */ +export const isValidCNPJ: typeof isValidCnpj = isValidCnpj; +/** + * Checks whether a CPF is valid, the 1.x name of `isValidCpf`. + * + * @deprecated Use `isValidCpf` instead. + */ +export const isValidCPF: typeof isValidCpf = isValidCpf; +/** + * Checks whether a state registration (inscrição estadual) is valid, the 1.x name of `isValidIe`. + * + * @deprecated Use `isValidIe` instead. + */ +export const isValidIE: typeof isValidIe = isValidIe; +/** + * Checks whether a PIS/PASEP is valid, the 1.x name of `isValidPis`. + * + * @deprecated Use `isValidPis` instead. + */ +export const isValidPIS: typeof isValidPis = isValidPis; diff --git a/src/is-business-day/is-business-day.test.ts b/src/is-business-day/is-business-day.test.ts index 138174b2..314b179c 100644 --- a/src/is-business-day/is-business-day.test.ts +++ b/src/is-business-day/is-business-day.test.ts @@ -1,16 +1,33 @@ import * as fc from "fast-check"; import { type StateCode } from "../_internals/constants/states"; -import { holidayYears, monthDays, monthIndexes, stateCodes } from "../_internals/test/arbitraries"; +import { + businessDayDates, + holidayYears, + monthDays, + monthIndexes, + stateCodes, +} from "../_internals/test/arbitraries"; import { expectNeverThrowsWithOptions } from "../_internals/test/properties"; import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; import { getHolidays, type Holiday } from "../get-holidays/get-holidays"; -import { isBusinessDay, type IsBusinessDayOptions } from "./is-business-day"; +import { isBusinessDay, type BusinessDayOptions } from "./is-business-day"; + +const PROTOTYPE_KEYS = Object.getOwnPropertyNames(Object.prototype); function getHolidaysFor(year: number, stateCode: StateCode | null): Holiday[] { return stateCode === null ? getHolidays(year) : getHolidays({ year, stateCode }); } +const anyStateCode = fc.oneof(fc.constantFrom(...PROTOTYPE_KEYS, "SP", "xx"), fc.anything()); +const anyIncludeOptional = fc.oneof(fc.boolean(), fc.anything()); +const hostileOptions = fc.record({ + stateCode: anyStateCode, + includeOptional: anyIncludeOptional, +}); +const anyValueInput = fc.oneof(fc.anything(), businessDayDates); +const anyOptionsInput = fc.oneof(fc.anything(), hostileOptions); + describe("isBusinessDay", () => { it("should return true for a plain weekday that is not a holiday (noon, DST-safe)", () => { expect(isBusinessDay(new Date(2024, 0, 2, 12))).toBe(true); @@ -51,6 +68,45 @@ describe("isBusinessDay", () => { // @ts-expect-error: intentionally invalid input expect(isBusinessDay(new Date(2024, 6, 9, 12), { stateCode: "XX" })).toBe(true); }); + + it("should return false for a stateCode that is present and is not a string, as isHoliday does, instead of ignoring it", () => { + // @ts-expect-error: intentionally invalid input + expect(isBusinessDay(new Date(2024, 6, 9, 12), { stateCode: 5 })).toBe(false); + // @ts-expect-error: intentionally invalid input + expect(isBusinessDay(new Date(2024, 6, 9, 12), { stateCode: null })).toBe(false); + // @ts-expect-error: intentionally invalid input + expect(isBusinessDay(new Date(2024, 6, 9, 12), { stateCode: {} })).toBe(false); + // @ts-expect-error: intentionally invalid input + expect(isBusinessDay(new Date(2024, 6, 9, 12), { stateCode: ["SP"] })).toBe(false); + }); + + it("should read an explicit undefined stateCode as no state at all, the only non-string value that is not rejected", () => { + expect(isBusinessDay(new Date(2024, 6, 9, 12), { stateCode: undefined })).toBe(true); + expect(isBusinessDay(new Date(2024, 0, 1, 12), { stateCode: undefined })).toBe(false); + }); + + it("should treat a prototype chain key as an unknown stateCode instead of throwing", () => { + for (const stateCode of PROTOTYPE_KEYS) { + // @ts-expect-error: intentionally invalid input + expect(isBusinessDay(new Date(2024, 6, 9, 12), { stateCode })).toBe(true); + // @ts-expect-error: intentionally invalid input + expect(isBusinessDay(new Date(2024, 0, 1, 12), { stateCode })).toBe(false); + } + }); + + it("should treat Monday 11/08/2025 as a business day in SC, since Lei SC nº 18.531/2022 moves the feriado to Sunday 17/08", () => { + expect(isBusinessDay(new Date(2025, 7, 11, 12), { stateCode: "SC" })).toBe(true); + expect(isBusinessDay(new Date(2025, 7, 17, 12), { stateCode: "SC" })).toBe(false); + }); + + it("should treat Corpus Christi as a non-business day in the DF even with includeOptional false, since Lei distrital nº 72/1989 declares it a feriado", () => { + expect( + isBusinessDay(new Date(2024, 4, 30, 12), { stateCode: "DF", includeOptional: false }), + ).toBe(false); + expect( + isBusinessDay(new Date(2024, 4, 30, 12), { stateCode: "SP", includeOptional: false }), + ).toBe(true); + }); }); describe("includeOptional", () => { @@ -151,8 +207,8 @@ describe("isBusinessDay", () => { ); }); - test("should never throw, regardless of the input", () => { - expectNeverThrowsWithOptions(isBusinessDay, fc.anything(), fc.anything()); + test("should never throw, regardless of the input, prototype chain state codes included", () => { + expectNeverThrowsWithOptions(isBusinessDay, anyValueInput, anyOptionsInput); }); }); }); @@ -160,9 +216,9 @@ describe("isBusinessDay", () => { describe("isBusinessDay types", () => { test("should take a Date, options, and return a boolean", () => { expectTypeOf(isBusinessDay).parameter(0).toEqualTypeOf(); - expectTypeOf(isBusinessDay).parameter(1).toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); + expectTypeOf(isBusinessDay).parameter(1).toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); expectTypeOf(isBusinessDay).returns.toEqualTypeOf(); }); }); diff --git a/src/is-business-day/is-business-day.ts b/src/is-business-day/is-business-day.ts index 23ee24a5..6fb40c49 100644 --- a/src/is-business-day/is-business-day.ts +++ b/src/is-business-day/is-business-day.ts @@ -1,9 +1,14 @@ -import { HOLIDAYS_MAX_YEAR, HOLIDAYS_MIN_YEAR } from "../_internals/constants/holidays"; import { type StateCode } from "../_internals/constants/states"; +import { isSupportedHolidayYear } from "../_internals/is-supported-holiday-year/is-supported-holiday-year"; import { getHolidays } from "../get-holidays/get-holidays"; -/** Options of `isBusinessDay`. */ -export type IsBusinessDayOptions = { +export type { StateCode } from "../_internals/constants/states"; + +/** + * Options shared by every business day util (`isBusinessDay`, `addBusinessDays`, + * `subBusinessDays` and `differenceInBusinessDays`): which holidays count as non-business days. + */ +export type BusinessDayOptions = { /** Two letter state code whose state holidays are also treated as non-business days (default: national holidays only). */ stateCode?: StateCode; /** Whether optional-type holidays (`Holiday.type === "optional"`, e.g. Carnaval, Corpus Christi) count as non-business days (default: `true`). */ @@ -27,19 +32,36 @@ const WEEKEND_DAYS = new Set([0, 6]); * they are not statutory holidays. Pass `false` to only treat statutory (`"national"` and * `"state"`) holidays as non-business days. * - * If `options.stateCode` is provided but is not a valid/known state code, it is ignored - * and only national holidays are considered (same behavior as `getHolidays`/`isHoliday`). + * An invalid `options.stateCode` is treated in two different ways, depending on its type, the + * same split `isHoliday` makes: + * + * - a string that is not a known state code is ignored, and only national holidays are + * considered, the same behavior as `getHolidays`. The lookup is an own-property one, so a + * prototype-chain key such as `"__proto__"` or `"constructor"` is an unknown state code like + * any other; + * - a `stateCode` that is present and is not a string at all (a number, `null`, an object) is + * rejected rather than ignored: `isBusinessDay` returns `false` without looking at the date, + * even when that date is an ordinary Tuesday. `undefined`, or an absent property, is the only + * non-string value that stands for "no state" instead. `addBusinessDays`, `subBusinessDays` + * and `differenceInBusinessDays` reject the same value with `null`. + * + * Two state rules change what `includeOptional: false` answers. The Distrito Federal declares + * Corpus Christi a feriado (Lei distrital nº 72/1989, art. 1º parágrafo único), so with + * `stateCode: "DF"` it is typed `"state"` and still counts; and Santa Catarina's two holidays + * are observed on the following Sunday when they fall Monday to Friday (Lei SC nº 18.531/2022), + * so 11 August 2025, a Monday, is a business day there. * * Only years from 1900 through 2099 are supported, the range `getHolidays` computes; a date * outside it returns `false` rather than silently treating every weekday as a business day. * * @param {Date} value - The date to check. - * @param {IsBusinessDayOptions} [options] - Options for the check. + * @param {BusinessDayOptions} [options] - Which holidays count as non-business days. * @param {StateCode} [options.stateCode] - Brazilian state code whose state holidays are also considered. * @param {boolean} [options.includeOptional] - Whether optional holidays count as non-business days (default: `true`). * @returns {boolean} True when `value` is a business day, false otherwise. Bad input also - * returns false: a `value` that is not a valid `Date` (including non-`Date` values) or a - * `value` outside the supported 1900-2099 range. + * returns false: a `value` that is not a valid `Date` (including non-`Date` values), a + * `value` outside the supported 1900-2099 range, or a `stateCode` that is present and is not a + * string. * * @example * ```typescript @@ -51,33 +73,45 @@ const WEEKEND_DAYS = new Set([0, 6]); * isBusinessDay(new Date(2024, 6, 9), { stateCode: "SP" }); // false (Revolução Constitucionalista) * isBusinessDay(new Date(2024, 6, 9)); // true (state holiday ignored without stateCode) * isBusinessDay(new Date("not a date")); // false + * isBusinessDay(new Date(2024, 6, 9), { stateCode: 5 }); // false (a non-string stateCode is rejected) * isBusinessDay(new Date(2100, 0, 4)); // false (a Monday, but 2100 is outside the supported range) * ``` * * The underlying holidays are the ones `getHolidays` computes; see its JSDoc (and * `src/get-holidays/constants.ts` for state holidays) for the full set of laws behind them. * - * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l0662.htm Lei 662/1949, the base - * national holidays law. - * @see Official: https://www.planalto.gov.br/ccivil_03/leis/2002/l10607.htm Lei 10.607/2002, - * added Tiradentes and Finados. - * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l6802.htm Lei 6.802/1980, declared - * Nossa Senhora Aparecida a national holiday. - * @see Official: https://www.planalto.gov.br/ccivil_03/_ato2023-2026/2023/lei/l14759.htm Lei - * 14.759/2023, nationalized Dia da Consciência Negra from 2024. - * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l9093.htm Lei 9.093/1995, the - * framework law authorizing state and municipal holidays. + * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l0662.htm + * Lei 662/1949, the base national holidays law. + * @see Official: https://www.planalto.gov.br/ccivil_03/leis/2002/l10607.htm + * Lei 10.607/2002, added Finados (2 November) and folded in Tiradentes (21 April), which had + * been national since art. 3º of the Lei 1.266/1950 it revoked. + * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l6802.htm + * Lei 6.802/1980, declared Nossa Senhora Aparecida a national holiday. + * @see Official: https://www.planalto.gov.br/ccivil_03/_ato2023-2026/2023/lei/l14759.htm + * Lei 14.759/2023, nationalized Dia da Consciência Negra from 2024. + * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l9093.htm + * Lei 9.093/1995, the framework law authorizing state and municipal holidays. + * @see Official: https://www.in.gov.br/web/dou/-/portaria-mgi-n-11.460-de-29-de-dezembro-de-2025-678388627 + * Portaria MGI nº 11.460/2025, the federal executive's annual calendar of feriados nacionais and + * pontos facultativos: the source of three of the four Easter-derived entries, namely + * Sexta-feira Santa being observed nationally and Carnaval and Corpus Christi being ponto + * facultativo, which is what `includeOptional` switches on. The fourth, Páscoa, has no entry in + * the portaria; `getHolidays` derives Easter Sunday arithmetically with the Meeus/Jones/Butcher + * algorithm, and it never affects this function because Easter is always a Sunday. */ -export const isBusinessDay = (value: Date, options?: IsBusinessDayOptions): boolean => { +export const isBusinessDay = (value: Date, options?: BusinessDayOptions): boolean => { if (!(value instanceof Date) || Number.isNaN(value.getTime())) return false; + const stateCode = options?.stateCode; + + if (stateCode !== undefined && typeof stateCode !== "string") return false; + const year = value.getFullYear(); - if (year < HOLIDAYS_MIN_YEAR || year > HOLIDAYS_MAX_YEAR) return false; + if (!isSupportedHolidayYear(year)) return false; if (WEEKEND_DAYS.has(value.getDay())) return false; - const stateCode = options?.stateCode; const includeOptional = options?.includeOptional ?? true; const month = value.getMonth(); diff --git a/src/is-holiday/is-holiday.test.ts b/src/is-holiday/is-holiday.test.ts index f2c53ed4..a5f9646f 100644 --- a/src/is-holiday/is-holiday.test.ts +++ b/src/is-holiday/is-holiday.test.ts @@ -11,6 +11,13 @@ function getHolidaysFor(year: number, stateCode: StateCode | null): Holiday[] { return stateCode === null ? getHolidays(year) : getHolidays({ year, stateCode }); } +const PROTOTYPE_KEYS = Object.getOwnPropertyNames(Object.prototype); + +const anyTargetDate = fc.oneof(fc.date(), fc.anything()); +const anyStateCode = fc.oneof(fc.constantFrom(...PROTOTYPE_KEYS, "SP", "xx"), fc.anything()); +const hostileOptions = fc.record({ targetDate: anyTargetDate, stateCode: anyStateCode }); +const anyInput = fc.oneof(fc.anything(), hostileOptions); + describe("isHoliday", () => { it("should return true for a national holiday built from local date components", () => { expect(isHoliday({ targetDate: new Date(2024, 0, 1) })).toBe(true); @@ -67,6 +74,15 @@ describe("isHoliday", () => { expect(isHoliday({ targetDate: new Date(2024, 5, 10), stateCode: "XX" })).toBe(false); }); + it("should treat a prototype chain key as an unknown stateCode instead of throwing", () => { + for (const stateCode of PROTOTYPE_KEYS) { + // @ts-expect-error: intentionally invalid input + expect(isHoliday({ targetDate: new Date(2024, 0, 1), stateCode })).toBe(true); + // @ts-expect-error: intentionally invalid input + expect(isHoliday({ targetDate: new Date(2024, 5, 10), stateCode })).toBe(false); + } + }); + describe("local calendar date vs UTC instant", () => { it("should read the local calendar day of a UTC-midnight instant, not its UTC day, deriving the expectation from the ambient zone (e.g. '2024-12-25' is local 2024-12-24 in America/Sao_Paulo, UTC-3) so the test is deterministic under vitest, bun and deno", () => { const utcMidnight = new Date("2024-12-25"); @@ -102,8 +118,8 @@ describe("isHoliday", () => { ); }); - test("should never throw, regardless of the input", () => { - expectNeverThrows(isHoliday, fc.anything()); + test("should never throw, regardless of the input, prototype chain state codes included", () => { + expectNeverThrows(isHoliday, anyInput); }); }); }); diff --git a/src/is-holiday/is-holiday.ts b/src/is-holiday/is-holiday.ts index b8f5b278..99bf2481 100644 --- a/src/is-holiday/is-holiday.ts +++ b/src/is-holiday/is-holiday.ts @@ -2,6 +2,8 @@ import { type StateCode } from "../_internals/constants/states"; import { isNullish } from "../_internals/is-nullish/is-nullish"; import { getHolidays } from "../get-holidays/get-holidays"; +export type { StateCode } from "../_internals/constants/states"; + /** The options `isHoliday` takes: the date to check and, optionally, the state whose holidays also count. */ export type IsHolidayOptions = { /** The date to check, read by its local calendar day. */ @@ -20,8 +22,22 @@ export type IsHolidayOptions = { * "2024-12-24" in local time, so build `targetDate` from local components * (`new Date(2024, 11, 25)`) or from a full ISO datetime when you mean a specific local day. * - * If `stateCode` is provided but is not a valid/known state code, it is ignored and only - * national holidays are considered (same behavior as `getHolidays`). + * An invalid `stateCode` is treated in two different ways, depending on its type: + * + * - a string that is not a known state code is ignored, and only national holidays are + * considered, the same behavior as `getHolidays`. The lookup is an own-property one, so a + * prototype-chain key such as `"__proto__"` or `"constructor"` is an unknown state code like + * any other; + * - a `stateCode` that is present and is not a string at all (a number, `null`, an object) is + * rejected rather than ignored: `isHoliday` returns `false` without looking at the date, even + * when that date is a national holiday. `undefined`, or an absent property, is the only + * non-string value that stands for "no state" instead. + * + * The date a state holiday is checked against is the statutory one, except for Santa Catarina's + * two holidays, which `getHolidays` moves to the following Sunday when they fall Monday to + * Friday: 11 August from 2005 on, as Lei SC nº 13.408/2005 introduced, and 25 November from 1999 + * on, as Lei SC nº 11.213/1999 introduced, save for 2004, the year art. 3º of Lei SC nº + * 12.906/2004 left that date without a transfer clause. Lei SC nº 18.531/2022 now carries both. * * @param {IsHolidayOptions} [options] - Options for the check. * @param {Date} options.targetDate - The date to check. @@ -40,16 +56,24 @@ export type IsHolidayOptions = { * The underlying national holidays are the ones `getHolidays` computes; see its JSDoc (and * `src/get-holidays/constants.ts` for state holidays) for the full set of laws behind them. * - * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l0662.htm Lei 662/1949, the base - * national holidays law. - * @see Official: https://www.planalto.gov.br/ccivil_03/leis/2002/l10607.htm Lei 10.607/2002, - * added Tiradentes and Finados. - * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l6802.htm Lei 6.802/1980, declared - * Nossa Senhora Aparecida a national holiday. - * @see Official: https://www.planalto.gov.br/ccivil_03/_ato2023-2026/2023/lei/l14759.htm Lei - * 14.759/2023, nationalized Dia da Consciência Negra from 2024. - * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l9093.htm Lei 9.093/1995, the - * framework law authorizing state and municipal holidays. + * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l0662.htm + * Lei 662/1949, the base national holidays law. + * @see Official: https://www.planalto.gov.br/ccivil_03/leis/2002/l10607.htm + * Lei 10.607/2002, added Finados (2 November) and folded in Tiradentes (21 April), which had + * been national since art. 3º of the Lei 1.266/1950 it revoked. + * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l6802.htm + * Lei 6.802/1980, declared Nossa Senhora Aparecida a national holiday. + * @see Official: https://www.planalto.gov.br/ccivil_03/_ato2023-2026/2023/lei/l14759.htm + * Lei 14.759/2023, nationalized Dia da Consciência Negra from 2024. + * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l9093.htm + * Lei 9.093/1995, the framework law authorizing state and municipal holidays. + * @see Official: https://www.in.gov.br/web/dou/-/portaria-mgi-n-11.460-de-29-de-dezembro-de-2025-678388627 + * Portaria MGI nº 11.460/2025, the federal executive's annual calendar of feriados nacionais and + * pontos facultativos, the source behind three of the four Easter-derived entries: Sexta-feira + * Santa, Carnaval and Corpus Christi. Páscoa is not one of them; the portaria never mentions + * Easter Sunday, whose date `getHolidays` derives arithmetically with the Meeus/Jones/Butcher + * algorithm. See the `getHolidays` JSDoc for why Sexta-feira Santa is typed `national` without a + * law of its own. */ export const isHoliday = (options?: IsHolidayOptions): boolean => { if (isNullish(options) || typeof options !== "object") { diff --git a/src/is-valid-bank-account/is-valid-bank-account.ts b/src/is-valid-bank-account/is-valid-bank-account.ts index bbfd2c8b..5d50e66c 100644 --- a/src/is-valid-bank-account/is-valid-bank-account.ts +++ b/src/is-valid-bank-account/is-valid-bank-account.ts @@ -23,7 +23,11 @@ export type IsValidBankAccountOptions = { agency: string; /** Account number, digits only, without the check digit. */ account: string; - /** The account check digit, one character. */ + /** + * The account check digit: one or two characters, or "X" for Banco do Brasil and "P" for + * Bradesco. Banks with a published rule take a single character; the generic fallback also + * accepts two, chaining mod10 and mod11 over the account. + */ digit: string; }; diff --git a/src/is-valid-boleto/is-valid-boleto.test.ts b/src/is-valid-boleto/is-valid-boleto.test.ts index fc5892d1..df85e9a7 100644 --- a/src/is-valid-boleto/is-valid-boleto.test.ts +++ b/src/is-valid-boleto/is-valid-boleto.test.ts @@ -63,6 +63,10 @@ describe("isValidBoleto", () => { test("when is a boleto valid with mask", () => { expect(isValidBoleto("0019000009 01149.718601 68524.522114 6 75860000102656")).toBe(true); }); + + test("when the código de moeda is not 9 (same fixture as the boleto valid without mask, with the moeda in barcode position 4 changed to 7 and both the campo 1 and the DV geral recalculated): Carta-Circular BCB nº 2.926/2000 fixes that position at 9, and the leniency kept from 2.3.0 accepts any other digit", () => { + expect(isValidBoleto("00170000010114971860168524522114275860000102656")).toBe(true); + }); }); describe("arrecadação", () => { @@ -166,6 +170,13 @@ describe("isValidBoleto", () => { }); }); +describe("isValidBoleto with an array of characters", () => { + test("should reject it instead of reading it as the joined string", () => { + // @ts-expect-error: intentionally invalid input + expect(isValidBoleto("34191790010104351004791020150008291070026000".match(/\d/g))).toBe(false); + }); +}); + describe("isValidBoleto types", () => { test("should take a string and return a boolean", () => { expectTypeOf(isValidBoleto).parameter(0).toEqualTypeOf(); diff --git a/src/is-valid-boleto/is-valid-boleto.ts b/src/is-valid-boleto/is-valid-boleto.ts index 56f148f8..5f016cfc 100644 --- a/src/is-valid-boleto/is-valid-boleto.ts +++ b/src/is-valid-boleto/is-valid-boleto.ts @@ -37,6 +37,10 @@ const isValidCheckDigit = (boleto: string): boolean => { * "arrecadação" (convênio/tributos) bank slip: 48 digit linha digitável or 44 digit * barcode, both starting with `8`. * + * One leniency is kept from 2.3.0: the código de moeda in position 4 of the cobrança bancária + * barcode is not checked, although Carta-Circular BCB nº 2.926/2000 fixes it at `9` (real), so + * a slip carrying any other moeda digit still validates. + * * @param {string} value - The bank slip number to validate. * @returns {boolean} True if the bank slip number is valid, false otherwise. * @@ -47,17 +51,17 @@ const isValidCheckDigit = (boleto: string): boolean => { * isValidBoleto("846100000005246100291102005460339004695895061080"); // true (arrecadação) * ``` * - * Carta-Circular BCB nº 2.926/2000 specifies the linha digitável fields, the módulo 11 check - * digit (using 1 for remainders 0, 10 and 1) and the fator de vencimento behind the 47 digit - * cobrança bancária slip; the FEBRABAN layout index covers the arrecadação slip. + * Carta-Circular BCB nº 2.926/2000 specifies the linha digitável fields and the módulo 11 + * check digit (using 1 for remainders 0, 10 and 1) of the 47 digit cobrança bancária slip, + * including the position of the fator de vencimento field. The FEBRABAN "Layout Padrão de + * Arrecadação/Recebimento com Utilização do Código de Barras" and the FEBRABAN layout index + * cover the arrecadação slip. * - * @see Official: https://cmsarquivos.febraban.org.br/Arquivos/documentos/PDF/Layout%20-%20C%C3%B3digo%20de%20Barras%20-%20Vers%C3%A3o%208%20-%2011_05_2026.pdf * @see Official: https://www.bcb.gov.br/pre/normativos/c_circ/2000/pdf/c_circ_2926_v1_O.pdf + * @see Official: https://cmsarquivos.febraban.org.br/Arquivos/documentos/PDF/Layout%20-%20C%C3%B3digo%20de%20Barras%20-%20Vers%C3%A3o%208%20-%2011_05_2026.pdf * @see Official: https://portal.febraban.org.br/pagina/3425/33/pt-br/layout-febraban */ export const isValidBoleto = (value: string): boolean => { - if (typeof value !== "string") return false; - const digits = sanitizeToDigits(value); if (digits.startsWith(ARRECADACAO_PRODUCT) && parseArrecadacao(digits)) return true; diff --git a/src/is-valid-caepf/constants.ts b/src/is-valid-caepf/constants.ts index d761533d..66bef577 100644 --- a/src/is-valid-caepf/constants.ts +++ b/src/is-valid-caepf/constants.ts @@ -3,13 +3,19 @@ * "000.000.000/000-00", the first 9 being the CPF base of the holder, the next 3 the sequence * of the holder's registrations and the last 2 the check digits. * + * The weights below are the CNPJ's modulus 11 in the formulation of the cited reference: read + * from the right they cycle from 9 down to 2, and the check digit is the remainder itself, with + * a remainder of 10 read as 0 — the same digit the CNPJ's 2-to-9 weights with `11 - remainder` + * produce. + * * The Receita Federal does not publish the check digit rule of the CAEPF, the shift of 12 * included, so the calculation follows the reference implementations cited below. * * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/orientacao-tributaria/cadastros/caepf * The registry's own page at the Receita Federal, which describes the cadastro but publishes * neither the 14 digit layout nor the check digit rule. - * @see Based on: http://ghiorzi.org/DVnew.htm Description of the CAEPF layout and of the + * @see Based on: http://ghiorzi.org/DVnew.htm + * Description of the CAEPF layout and of the * shift of 12 applied to the check digit pair. * @see Based on: https://github.com/VitorLuizC/brazilian-values/blob/master/src/validators/isCAEPF.ts * Reference implementation agreeing on the weights and on the shift. diff --git a/src/is-valid-caepf/is-valid-caepf.test.ts b/src/is-valid-caepf/is-valid-caepf.test.ts index 887340c2..0335de0e 100644 --- a/src/is-valid-caepf/is-valid-caepf.test.ts +++ b/src/is-valid-caepf/is-valid-caepf.test.ts @@ -38,6 +38,11 @@ describe("isValidCaepf", () => { expect(isValidCaepf("abc.118.610/001-84")).toBe(false); }); + test("when a valid registration is followed or preceded by a letter", () => { + expect(isValidCaepf("293.118.610/001-84a")).toBe(false); + expect(isValidCaepf("a293.118.610/001-84")).toBe(false); + }); + test("when it has 14 digits but an unsupported separator", () => { expect(isValidCaepf("293#118#610#001#84")).toBe(false); }); @@ -47,6 +52,12 @@ describe("isValidCaepf", () => { expect(isValidCaepf("11111111111111")).toBe(false); }); + test("when the 12 digit base is a repeated digit, as isValidCei and isValidCno reject it", () => { + expect(isValidCaepf("00000000000012")).toBe(false); + expect(isValidCaepf("000.000.000/000-12")).toBe(false); + expect(isValidCaepf("11111111111192")).toBe(false); + }); + test("when the check digits do not match (29311861000185, Casilhero/brazilian-validators CaepfTest)", () => { expect(isValidCaepf("29311861000185")).toBe(false); }); diff --git a/src/is-valid-caepf/is-valid-caepf.ts b/src/is-valid-caepf/is-valid-caepf.ts index f3679710..0dabe6c5 100644 --- a/src/is-valid-caepf/is-valid-caepf.ts +++ b/src/is-valid-caepf/is-valid-caepf.ts @@ -1,5 +1,7 @@ import { generateChecksum } from "../_internals/generate-checksum/generate-checksum"; +import { isRepeatedDigits } from "../_internals/is-repeated-digits/is-repeated-digits"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { toStringSafe } from "../_internals/to-string-safe/to-string-safe"; import { CAEPF_BASE_LENGTH, CAEPF_CHECK_DIGITS_OFFSET, @@ -17,12 +19,19 @@ const getCheckDigit = (base: string, weights: number[]): number => * The CAEPF replaced the CEI for individuals who hire employees, such as rural producers and * notary officials. It has 14 digits printed as "000.000.000/000-00": the 9 digit CPF base of * the holder, a 3 digit sequence for the holder's several registrations and 2 check digits. - * Both check digits use the modulus 11 of the CNPJ, weights cycling from 2 to 9 from the right, - * with a remainder of 10 read as 0. The pair is then shifted by 12, wrapping around 100, so a - * CAEPF whose plain modulus 11 digits would be 72 is printed with 84. + * Both check digits are the CNPJ's modulus 11 in the formulation of the cited reference: the + * weights cycle from 9 down to 2 from the right and the check digit is the remainder itself, + * with a remainder of 10 read as 0 — the same digit the CNPJ's 2-to-9 weights with + * `11 - remainder` produce. The pair is then shifted by 12, wrapping around 100, so a CAEPF + * whose plain modulus 11 digits would be 72 is printed with 84. * - * The Receita Federal does not publish the check digit rule of the CAEPF, the shift of 12 - * included, so the calculation follows the reference implementations cited below. + * A base whose 12 digits are all the same is rejected before the check digits are computed, the + * way `isValidCei` and `isValidCno` reject a repeated CEI/CNO number, so the otherwise + * well-formed `"00000000000012"` is invalid. + * + * The Receita Federal does not publish the check digit rule of the CAEPF, the shift of 12 and + * the repeated-base rejection included, so the calculation follows the reference implementations + * cited below. * * @param {string|number} value - The CAEPF value to be validated. * @returns {boolean} True if the CAEPF is valid, false otherwise. @@ -33,13 +42,15 @@ const getCheckDigit = (base: string, weights: number[]): number => * isValidCaepf("41142260000101"); // true * isValidCaepf(29311861000184); // true * isValidCaepf("29311861000185"); // false (invalid check digits) - * isValidCaepf("00000000000000"); // false (repeated digits) + * isValidCaepf("00000000000000"); // false (repeated base digits) + * isValidCaepf("00000000000012"); // false (repeated base digits) * ``` * * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/orientacao-tributaria/cadastros/caepf * The registry's own page at the Receita Federal, which describes the cadastro but publishes * neither the 14 digit layout nor the check digit rule. - * @see Based on: http://ghiorzi.org/DVnew.htm Description of the CAEPF layout and of the + * @see Based on: http://ghiorzi.org/DVnew.htm + * Description of the CAEPF layout and of the * shift of 12 applied to the check digit pair. * @see Based on: https://github.com/VitorLuizC/brazilian-values/blob/master/src/validators/isCAEPF.ts * Reference implementation agreeing on the weights and on the shift. @@ -47,13 +58,14 @@ const getCheckDigit = (base: string, weights: number[]): number => * Third reference implementation. */ export const isValidCaepf = (value: string | number): boolean => { - if (typeof value !== "string" && typeof value !== "number") return false; - const digits = sanitizeToDigits(value); - if (!CAEPF_FORMAT_REGEX.test(String(value).trim())) return false; + if (!CAEPF_FORMAT_REGEX.test(toStringSafe(value).trim())) return false; const base = digits.slice(0, CAEPF_BASE_LENGTH); + + if (isRepeatedDigits(base)) return false; + const first = getCheckDigit(base, CAEPF_FIRST_WEIGHTS); const second = getCheckDigit(`${base}${first}`, CAEPF_SECOND_WEIGHTS); const expected = (first * 10 + second + CAEPF_CHECK_DIGITS_OFFSET) % 100; diff --git a/src/is-valid-cbo/is-valid-cbo.test.ts b/src/is-valid-cbo/is-valid-cbo.test.ts index 1fb0f7f3..266d0b27 100644 --- a/src/is-valid-cbo/is-valid-cbo.test.ts +++ b/src/is-valid-cbo/is-valid-cbo.test.ts @@ -19,10 +19,15 @@ describe("isValidCbo", () => { expect(isValidCbo(212_405)).toBe(true); }); - it("should pad a number with leading zeros before looking it up", () => { + it("should pad a value with leading zeros before looking it up, as a number or as a string", () => { expect(isValidCbo(10_205)).toBe(true); expect(isValidCbo("010205")).toBe(true); - expect(isValidCbo("10205")).toBe(false); + expect(isValidCbo("10205")).toBe(true); + }); + + it("should not pad a masked value, which already carries its separators", () => { + expect(isValidCbo("102-05")).toBe(false); + expect(isValidCbo("0102-05")).toBe(true); }); it("should validate a CBO code with surrounding whitespace", () => { @@ -47,7 +52,7 @@ describe("isValidCbo", () => { expect(isValidCbo("000000")).toBe(false); }); - it("should return false when the digit count is not six", () => { + it("should return false for a padded short value no occupation carries and for a wider value", () => { expect(isValidCbo("21240")).toBe(false); expect(isValidCbo("2124055")).toBe(false); }); diff --git a/src/is-valid-cbo/is-valid-cbo.ts b/src/is-valid-cbo/is-valid-cbo.ts index 676b56b3..2064d844 100644 --- a/src/is-valid-cbo/is-valid-cbo.ts +++ b/src/is-valid-cbo/is-valid-cbo.ts @@ -9,6 +9,10 @@ import { getCbo } from "../get-cbo/get-cbo"; * surrounding whitespace. A number is only read as a code when it is a non-negative safe * integer. * + * A CBO code is always 6 digits and its leading zeros are part of it, so a value written as + * bare digits is left padded with zeros to 6 whether it comes as a string or as a number: + * `10205`, `"10205"` and `"010205"` are the same code. + * * @param {string|number} value - The CBO code to be validated, with or without the hyphen * mask, e.g. `"2124-05"`, `"212405"` or `212405`. * @returns {boolean} True when the code is a known 6 digit occupation code, false otherwise. @@ -18,7 +22,8 @@ import { getCbo } from "../get-cbo/get-cbo"; * isValidCbo("2124-05"); // true * isValidCbo("212405"); // true * isValidCbo(212405); // true - * isValidCbo(10205); // true (a number is padded to 6 digits, so this is "010205") + * isValidCbo(10205); // true (padded to 6 digits, so this is "010205") + * isValidCbo("10205"); // true (padded to 6 digits, so this is "010205") * isValidCbo("999999"); // false * isValidCbo("2124abc05"); // false (not a documented form) * isValidCbo(-212405); // false (not a non-negative safe integer) diff --git a/src/is-valid-cei/is-valid-cei.test.ts b/src/is-valid-cei/is-valid-cei.test.ts index 2850055a..4d8d7bd3 100644 --- a/src/is-valid-cei/is-valid-cei.test.ts +++ b/src/is-valid-cei/is-valid-cei.test.ts @@ -46,6 +46,11 @@ describe("isValidCei", () => { expect(isValidCei("aa.583.00249/85")).toBe(false); }); + test("when a valid registration is followed or preceded by a letter", () => { + expect(isValidCei("11.583.00249/85a")).toBe(false); + expect(isValidCei("a11.583.00249/85")).toBe(false); + }); + test("when every digit is the same", () => { expect(isValidCei("000000000000")).toBe(false); expect(isValidCei("111111111111")).toBe(false); diff --git a/src/is-valid-cei/is-valid-cei.ts b/src/is-valid-cei/is-valid-cei.ts index f40e8e72..2f88fe50 100644 --- a/src/is-valid-cei/is-valid-cei.ts +++ b/src/is-valid-cei/is-valid-cei.ts @@ -10,6 +10,11 @@ import { isValidCeiCnoNumber } from "../_internals/is-valid-cei-cno-number/is-va * mapping 10 back to 0. The CEI was replaced by the CNO for construction works and by the CAEPF * for individuals, but numbers already issued keep their meaning and their check digit. * + * The value has to be written as the 12 digits, optionally split into the printed groups of 2, + * 3, 5 and 2 by whitespace or the usual mask characters, a run of them between two groups + * included; anything else, a letter among the digits included, is rejected instead of being + * read past. + * * The Receita Federal does not publish the check digit rule of the CEI/CNO numbering, so the * calculation follows the reference implementations cited below, cross-checked against the CNO * open data of the Receita Federal. @@ -30,9 +35,11 @@ import { isValidCeiCnoNumber } from "../_internals/is-valid-cei-cno-number/is-va * The registry's own page at the Receita Federal, which describes the cadastro but publishes * neither the mask nor the check digit rule. * @see Official: https://dados.gov.br/dados/conjuntos-dados/cadastro-nacional-de-obras-cno - * Cadastro Nacional de Obras (CNO), dados abertos da Receita Federal: every one of the 38432 - * works registered in Minas Gerais passes this check, which is what ties the CNO to the CEI - * rule and where the test vectors come from. + * Cadastro Nacional de Obras (CNO), dados abertos da Receita Federal: the catalogue entry for the + * dataset this rule was cross-checked against and where the test vectors come from. The check was + * run over the Minas Gerais extract of the downloaded dataset, which every registered work passed; + * the catalogue page itself publishes only the dataset's description and download links (and + * currently flags it "Desatualizado"), not that result. * @see Based on: https://github.com/yiibr/yii2-br-validator/blob/master/src/CeiValidator.php * PHP reference implementation of the CEI check digit. * @see Based on: https://github.com/marcos-cruz/Documento/blob/master/src/Bigai.Documentos.Brasil/Cei/Cei.cs diff --git a/src/is-valid-certidao/is-valid-certidao.test.ts b/src/is-valid-certidao/is-valid-certidao.test.ts index 66652fe2..d41d21f3 100644 --- a/src/is-valid-certidao/is-valid-certidao.test.ts +++ b/src/is-valid-certidao/is-valid-certidao.test.ts @@ -1,8 +1,8 @@ import * as fc from "fast-check"; import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; -import { CERTIDAO_TYPES } from "../parse-certidao/constants"; -import { type CertidaoType } from "../parse-certidao/parse-certidao"; +import { CERTIDAO_TYPES } from "../get-certidao-info/constants"; +import { type CertidaoType } from "../get-certidao-info/get-certidao-info"; import { isValidCertidao, type IsValidCertidaoOptions } from "./is-valid-certidao"; const CHECK_DIGIT_PAIRS = Array.from({ length: 100 }, (_, index) => String(index).padStart(2, "0")); diff --git a/src/is-valid-certidao/is-valid-certidao.ts b/src/is-valid-certidao/is-valid-certidao.ts index f8ea9e54..24b91063 100644 --- a/src/is-valid-certidao/is-valid-certidao.ts +++ b/src/is-valid-certidao/is-valid-certidao.ts @@ -5,8 +5,8 @@ import { CERTIDAO_SERVICE_CODE, } from "../_internals/constants/certidao"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; -import { CERTIDAO_TYPES } from "../parse-certidao/constants"; -import { type CertidaoType } from "../parse-certidao/parse-certidao"; +import { CERTIDAO_TYPES } from "../get-certidao-info/constants"; +import { type CertidaoType } from "../get-certidao-info/get-certidao-info"; /** Options of `isValidCertidao`. */ export type IsValidCertidaoOptions = { @@ -43,8 +43,8 @@ const getCheckDigit = (value: string): number => { * as 1. * * The book-type digit (fifteenth position of the matrícula) always has to name one of the nine - * books (see `CertidaoType`, reused from `parseCertidao`), so a matrícula whose digit is `0` is - * rejected however good its check digits are, the same way `parseCertidao` returns `null` for + * books (see `CertidaoType`, reused from `getCertidaoInfo`), so a matrícula whose digit is `0` is + * rejected however good its check digits are, the same way `getCertidaoInfo` returns `null` for * it. `options.accept` narrows that further to the listed types; when it is omitted, or when it * is not an array, every book type is accepted. * @@ -67,14 +67,25 @@ const getCheckDigit = (value: string): number => { * isValidCertidao("104539 01 55 2013 1 00012 021 0000123 21", { accept: ["death"] }); // false * ``` * - * @see Official: https://atos.cnj.jus.br/atos/detalhar/5243 Código Nacional de Normas da - * Corregedoria Nacional de Justiça - Foro Extrajudicial (Provimento CNJ nº 149/2023), art. 473 - * in the wording of the Provimento CN nº 182, de 17/09/2024: the in-force layout of the 32 - * digit matrícula. - * @see Official: https://atos.cnj.jus.br/atos/detalhar/1311 Provimento CNJ nº 2, de 27/04/2009, - * which instituted the modelos únicos de certidão and the matrícula (revoked; historical). - * @see Based on: http://ghiorzi.org/DVnew.htm Worked example of the two check digits - * (sums 288 and 309). + * @see Official: https://atos.cnj.jus.br/atos/detalhar/5243 + * Código Nacional de Normas da Corregedoria Nacional de Justiça - Foro Extrajudicial (Provimento + * CNJ nº 149/2023), art. 473 as currently published: the in-force layout of the 32 digit + * matrícula. Inciso II and §§ 1º and 3º to 5º carry the redação of the Provimento CN nº 237, de + * 13/07/2026; the rest of the article, § 2º included, and the digit layout this library depends + * on, come from the Provimento CN nº 182, de 17/09/2024. + * @see Official: https://atos.cnj.jus.br/atos/detalhar/1311 + * Provimento CNJ nº 2, de 27/04/2009, art. 1º and 2º, which instituted the modelos únicos de + * certidão and ordered that "as certidões passarão a consignar matrícula que identifica o código + * nacional da serventia, o código do acervo, o tipo do serviço prestado, o tipo do livro, o número + * do livro, o número da folha, o número do termo e o digito verificador" (revoked; historical). + * @see Official: https://atos.cnj.jus.br/atos/detalhar/1310 + * Provimento CNJ nº 3, de 17/11/2009, art. 7º, which is where that matrícula first got its digit + * structure: "a matrícula, de inserção obrigatória nas certidões (primeira e demais vias) emitidas + * pelos Cartórios de Registro Civil das Pessoas Naturais a partir de 1º de janeiro de 2010, é + * formada pelos seguintes elementos", incisos I to IX fixing the same 6 + 2 + 2 + 4 + 1 + 5 + 3 + + * 7 + 2 positions art. 473 carries today (revoked; historical). + * @see Based on: http://ghiorzi.org/DVnew.htm + * Worked example of the two check digits (sums 288 and 309). * @see Based on: https://github.com/klawdyo/validation-br/blob/feat-certidao/src/certidao.ts * Reference implementation, and the source of the matrículas used as test vectors. * @see Based on: https://github.com/geekcom/validator-docs/blob/master/src/validator-docs/Rules/Certidao.php diff --git a/src/is-valid-cfop/is-valid-cfop.ts b/src/is-valid-cfop/is-valid-cfop.ts index d04a0f4a..e50bb6b4 100644 --- a/src/is-valid-cfop/is-valid-cfop.ts +++ b/src/is-valid-cfop/is-valid-cfop.ts @@ -20,6 +20,10 @@ import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-d * safe integer, since a sign, a decimal point or a rounded magnitude would otherwise be read * as a code the caller never wrote. * + * No CFOP code starts with a zero, its first digit is the operation group (1 to 7), so nothing + * is ever padded here: a number and the string of the same digits are read identically, and a + * value narrower than 4 digits is not a code at all. + * * @param {string|number} value - The CFOP code to be validated, with or without the `N.NNN` * mask, e.g. `"1.101"`, `"1101"` or `1101`. * @returns {boolean} True when the code is a known 4 digit CFOP code, false otherwise. @@ -39,6 +43,8 @@ import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-d * Anexo II of Convênio SINIEF s/nº 1970, the CFOP table in force. * @see Official: https://www.confaz.fazenda.gov.br/legislacao/ajustes/sinief/cvsn_70 * Convênio SINIEF s/nº 1970, the consolidated text the annex belongs to. + * @see Official: https://www.confaz.fazenda.gov.br/legislacao/ajustes/2025/AJ039_25 + * Ajuste SINIEF 39/25, the last amendment the annex carries (CFOP 7.667, from 01.02.26). * @see Official: https://www.confaz.fazenda.gov.br/legislacao/ajustes/2001/AJ_007_01 * Ajuste SINIEF 07/01, the historical text that gave the CFOP its 4 digit form. */ diff --git a/src/is-valid-cnae/is-valid-cnae.test.ts b/src/is-valid-cnae/is-valid-cnae.test.ts index 4ca593ea..bc655973 100644 --- a/src/is-valid-cnae/is-valid-cnae.test.ts +++ b/src/is-valid-cnae/is-valid-cnae.test.ts @@ -19,10 +19,15 @@ describe("isValidCnae", () => { expect(isValidCnae(6_201_501)).toBe(true); }); - it("should pad a number with leading zeros before looking it up", () => { + it("should pad a value with leading zeros before looking it up, as a number or as a string", () => { expect(isValidCnae(111_301)).toBe(true); expect(isValidCnae("0111301")).toBe(true); - expect(isValidCnae("111301")).toBe(false); + expect(isValidCnae("111301")).toBe(true); + }); + + it("should not pad a masked value, which already carries its separators", () => { + expect(isValidCnae("111-3/01")).toBe(false); + expect(isValidCnae("0111-3/01")).toBe(true); }); it("should validate a CNAE code with surrounding whitespace", () => { @@ -38,7 +43,7 @@ describe("isValidCnae", () => { expect(isValidCnae("0000000")).toBe(false); }); - it("should return false when the digit count is not seven", () => { + it("should return false for a padded short value no subclass carries and for a wider value", () => { expect(isValidCnae("620150")).toBe(false); expect(isValidCnae("62015011")).toBe(false); }); diff --git a/src/is-valid-cnae/is-valid-cnae.ts b/src/is-valid-cnae/is-valid-cnae.ts index 2257c1f6..6a481bdd 100644 --- a/src/is-valid-cnae/is-valid-cnae.ts +++ b/src/is-valid-cnae/is-valid-cnae.ts @@ -2,13 +2,17 @@ import { getCnae } from "../get-cnae/get-cnae"; /** * Validates if a CNAE (Classificação Nacional de Atividades Econômicas) subclass code - * exists in the official CNAE 2.3 table. + * exists in the official CNAE-Subclasses 2.3 table, the current subclass revision of CNAE 2.0. * * A string is only read as a code when it is written in one of the documented forms: the 7 * digits, or the `NNNN-N/NN` mask, with a single separator (space, `.`, `-` or `/`) between the groups and optional * surrounding whitespace. A number is only read as a code when it is a non-negative safe * integer. * + * A CNAE subclass code is always 7 digits and its leading zeros are part of it, so a value + * written as bare digits is left padded with zeros to 7 whether it comes as a string or as a + * number: `111301`, `"111301"` and `"0111301"` are the same code. + * * @param {string|number} value - The CNAE code to be validated, with or without the * `NNNN-N/NN` mask, e.g. `"6201-5/01"`, `"6201501"` or `6201501`. * @returns {boolean} True when the code is a known 7 digit subclass, false otherwise. @@ -18,12 +22,15 @@ import { getCnae } from "../get-cnae/get-cnae"; * isValidCnae("6201-5/01"); // true * isValidCnae("6201501"); // true * isValidCnae(6201501); // true - * isValidCnae(111301); // true (a number is padded to 7 digits, so this is "0111301") + * isValidCnae(111301); // true (padded to 7 digits, so this is "0111301") + * isValidCnae("111301"); // true (padded to 7 digits, so this is "0111301") * isValidCnae("0000000"); // false * isValidCnae("0111abc301"); // false (not a documented form) * isValidCnae(-111301); // false (not a non-negative safe integer) * ``` * * @see Official: https://servicodados.ibge.gov.br/api/v2/cnae/subclasses + * @see Official: https://concla.ibge.gov.br/busca-online-cnae.html + * CONCLA's CNAE search and structure browser, which publishes CNAE-Subclasses 2.3. */ export const isValidCnae = (value: string | number): boolean => getCnae(value) !== null; diff --git a/src/is-valid-cno/is-valid-cno.ts b/src/is-valid-cno/is-valid-cno.ts index f49fa7d3..9eb6cc86 100644 --- a/src/is-valid-cno/is-valid-cno.ts +++ b/src/is-valid-cno/is-valid-cno.ts @@ -9,6 +9,11 @@ import { isValidCeiCnoNumber } from "../_internals/is-valid-cei-cno-number/is-va * the weights 7, 4, 1, 8, 5, 2, 1, 6, 3, 7 and 4. A work registered under a legacy CEI keeps * the same number in the CNO, so both registries validate identically. * + * The value has to be written as the 12 digits, optionally split into the printed groups of 2, + * 3, 5 and 2 by whitespace or the usual mask characters, a run of them between two groups + * included; anything else, a letter among the digits included, is rejected instead of being + * read past. + * * The Receita Federal does not publish the check digit rule of the CEI/CNO numbering, so the * calculation follows the reference implementations cited below, cross-checked against the CNO * open data of the Receita Federal. @@ -29,9 +34,11 @@ import { isValidCeiCnoNumber } from "../_internals/is-valid-cei-cno-number/is-va * The registry's own page at the Receita Federal, which describes the cadastro but publishes * neither the mask nor the check digit rule. * @see Official: https://dados.gov.br/dados/conjuntos-dados/cadastro-nacional-de-obras-cno - * Cadastro Nacional de Obras (CNO), dados abertos da Receita Federal: every one of the 38432 - * works registered in Minas Gerais passes this check, which is what ties the CNO to the CEI - * rule and where the test vectors come from. + * Cadastro Nacional de Obras (CNO), dados abertos da Receita Federal: the catalogue entry for the + * dataset this rule was cross-checked against and where the test vectors come from. The check was + * run over the Minas Gerais extract of the downloaded dataset, which every registered work passed; + * the catalogue page itself publishes only the dataset's description and download links (and + * currently flags it "Desatualizado"), not that result. * @see Based on: https://github.com/yiibr/yii2-br-validator/blob/master/src/CeiValidator.php * PHP reference implementation of the CEI check digit. */ diff --git a/src/is-valid-cns/is-valid-cns.test.ts b/src/is-valid-cns/is-valid-cns.test.ts index 61f00a0c..b8c66d14 100644 --- a/src/is-valid-cns/is-valid-cns.test.ts +++ b/src/is-valid-cns/is-valid-cns.test.ts @@ -44,6 +44,11 @@ describe("isValidCns", () => { expect(isValidCns([])).toBe(false); }); + test("when it is an array whose text reads as a valid card", () => { + // @ts-expect-error: intentionally invalid input + expect(isValidCns(["123456789010000"])).toBe(false); + }); + test("when it is an empty string", () => { expect(isValidCns("")).toBe(false); }); @@ -122,6 +127,13 @@ describe("isValidCns", () => { expect(isValidCns("123.4567.8901.0000")).toBe(true); }); + test("for a definitive CNS split by the other interchangeable separators", () => { + expect(isValidCns("123-4567-8901-0000")).toBe(true); + expect(isValidCns("123/4567/8901/0000")).toBe(true); + expect(isValidCns("123.4567-8901/0000")).toBe(true); + expect(isValidCns("123 - 4567 8901 0000")).toBe(true); + }); + test("for a definitive CNS with leading and trailing whitespace", () => { expect(isValidCns(" 123456789010000 ")).toBe(true); }); @@ -138,6 +150,11 @@ describe("isValidCns", () => { expect(isValidCns("800000000000001")).toBe(true); }); + test("for 898 0000 0004 3208, the only concrete CNS the ANVISA page prints (weighted sum 396)", () => { + expect(isValidCns("898000000043208")).toBe(true); + expect(isValidCns("898 0000 0004 3208")).toBe(true); + }); + test("for a provisional CNS starting with 9", () => { expect(isValidCns("900000000000008")).toBe(true); }); diff --git a/src/is-valid-cns/is-valid-cns.ts b/src/is-valid-cns/is-valid-cns.ts index 3e4e7b34..50f3a4a1 100644 --- a/src/is-valid-cns/is-valid-cns.ts +++ b/src/is-valid-cns/is-valid-cns.ts @@ -42,8 +42,10 @@ const isValidProvisional = (digits: string): boolean => * be a multiple of 11. * * The value has to be written as the 15 digits, optionally split into the printed groups of 3, - * 4, 4 and 4 by whitespace or the usual mask characters; anything else, a letter among the - * digits included, is rejected instead of being read past. + * 4, 4 and 4 by whitespace, `.`, `-` or `/`, the interchangeable mask characters `isValidCpf` + * and `isValidCnpj` accept, a run of them between two groups included; anything else, a letter + * among the digits or a separator inside a group included, is rejected instead of being read + * past. * * @param {string|number} value - The CNS value to be validated. * @returns {boolean} True if the CNS is valid, false otherwise. @@ -53,13 +55,19 @@ const isValidProvisional = (digits: string): boolean => * isValidCns("123456789010000"); // true (definitive, suffix 000) * isValidCns("100000000060018"); // true (definitive, raw check digit 10, suffix 001) * isValidCns("700000000000005"); // true (provisional) + * isValidCns("123.4567-8901/0000"); // true (any of the mask characters) * isValidCns("123456789010001"); // false (wrong check digit) * isValidCns("12345678901"); // false (wrong length) * ``` * * @see Official: https://rni-docs.anvisa.gov.br/docs/regras_gerais/validacoes/validacaoCNS/ + * ANVISA's two validation routines, the ones implemented here. The page sits behind a bot filter + * and answers HTTP 403 to every non-browser client, so it has to be opened in a browser. * @see Based on: https://integracao.esusab.ufsc.br/ledi/documentacao/regras/algoritmo_CNS.html - * e-SUS APS documentation of the same DATASUS algorithm, reachable without a browser. + * e-SUS APS documentation of the same DATASUS algorithm, reachable without a browser. It applies + * the provisional routine to numbers starting with 5, 7, 8 or 9; this implementation follows the + * ANVISA page, which restricts it to 7, 8 and 9, so a 5 prefixed number is rejected even when its + * weighted sum checks out. */ export const isValidCns = (value: string | number): boolean => { if (typeof value !== "string" && typeof value !== "number") return false; diff --git a/src/is-valid-cpf/is-valid-cpf.test.ts b/src/is-valid-cpf/is-valid-cpf.test.ts index 13abe749..3d153dd4 100644 --- a/src/is-valid-cpf/is-valid-cpf.test.ts +++ b/src/is-valid-cpf/is-valid-cpf.test.ts @@ -94,6 +94,11 @@ describe("isValidCpf", () => { expect(isValidCpf("12345678909 ")).toBe(true); }); + test("when it is the worked example the RFB Manual da e-Financeira prints", () => { + expect(isValidCpf("28001238938")).toBe(true); + expect(isValidCpf("280.012.389-38")).toBe(true); + }); + test("should return true for randomly generated CPFs", () => { for (let i = 0; i < 100; i++) { expect(isValidCpf(generateCpf())).toBe(true); diff --git a/src/is-valid-cpf/is-valid-cpf.ts b/src/is-valid-cpf/is-valid-cpf.ts index 26fa1ded..6c33bfb9 100644 --- a/src/is-valid-cpf/is-valid-cpf.ts +++ b/src/is-valid-cpf/is-valid-cpf.ts @@ -38,11 +38,18 @@ const isValidChecksum = (cpf: string): boolean => { * isValidCpf("12345678900"); // false (invalid checksum) * ``` * - * The check digit rule (`REGRA_VALIDA_CPF`) is specified, with a worked example - * (`280012389-38`), in the Receita Federal's Manual e-Financeira, Anexo II. + * The check digit rule (`REGRA_VALIDA_CPF`) is specified, with the worked example + * `280012389-38`, in the Receita Federal's Manual de Preenchimento da e-Financeira, Anexo II — + * Leiautes Gerais, approved by the Ato Declaratório Executivo Cofis nº 10, de 19 de maio de + * 2026 (DOU de 25/05/2026). The manual states the rule in its mirror form, weights 9 down to 1 "a partir da + * unidade" with "o resto 10 é considerado 0", which is algebraically the same digit as the + * weights 10 down to 2 with `11 - resto` implemented above. The manual's own file used to be + * served from `sped.rfb.gov.br`, a host that no longer answers at all, so the approving act is + * cited below in its place; its Receita Federal permalink redirects into the norms viewer, which + * has to be opened in a browser. * * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/meu-cpf - * @see Official: http://sped.rfb.gov.br/arquivo/show/8231 + * @see Official: https://normas.receita.fazenda.gov.br/sijut2consulta/link.action?idAto=151372 * @see Based on: https://github.com/brazilian-utils/python/blob/main/brutils/cpf.py */ export const isValidCpf = (cpf: string): boolean => { diff --git a/src/is-valid-credit-card/is-valid-credit-card.test.ts b/src/is-valid-credit-card/is-valid-credit-card.test.ts index 121d1664..ea04f28c 100644 --- a/src/is-valid-credit-card/is-valid-credit-card.test.ts +++ b/src/is-valid-credit-card/is-valid-credit-card.test.ts @@ -35,6 +35,17 @@ describe("isValidCreditCard", () => { expect(isValidCreditCard("4111-1111-1111-1111")).toBe(true); }); + test("for a value masked with the other interchangeable separators", () => { + expect(isValidCreditCard("4111.1111.1111.1111")).toBe(true); + expect(isValidCreditCard("4111/1111/1111/1111")).toBe(true); + expect(isValidCreditCard("4111.1111/1111-1111")).toBe(true); + }); + + test("for a separator between any two digits, since the grouping changes with the brand", () => { + expect(isValidCreditCard("3782-822463-10005")).toBe(true); + expect(isValidCreditCard("4.1.1.1.1.1.1.1.1.1.1.1.1.1.1.1")).toBe(true); + }); + test("for a value whose digit groups are separated by a run of mask characters", () => { expect(isValidCreditCard("4111 - 1111 - 1111 - 1111")).toBe(true); expect(isValidCreditCard("4111 1111 1111 1111")).toBe(true); @@ -58,6 +69,17 @@ describe("isValidCreditCard", () => { expect(isValidCreditCard("4111111111111112")).toBe(false); }); + test("when every digit is the same, even though the Luhn check passes", () => { + expect(isValidCreditCard("0000000000000000")).toBe(false); + expect(isValidCreditCard("000000000000")).toBe(false); + expect(isValidCreditCard("8888888888888888")).toBe(false); + expect(isValidCreditCard("0000000000000000000")).toBe(false); + }); + + test("when every digit is the same behind a mask, even though the Luhn check passes", () => { + expect(isValidCreditCard("0000 0000 0000 0000")).toBe(false); + }); + test("when it has fewer than 12 digits (11 digits)", () => { expect(isValidCreditCard("60110000000")).toBe(false); }); @@ -66,6 +88,10 @@ describe("isValidCreditCard", () => { expect(isValidCreditCard("12345678901234567850")).toBe(false); }); + test("when it has more than 19 digits and would pass the Luhn check on its own", () => { + expect(isValidCreditCard("12345678901234567852")).toBe(false); + }); + test("when it has more than 19 digits and would still pass the Luhn check on its own", () => { expect(isValidCreditCard("00000000000000000000")).toBe(false); }); @@ -103,8 +129,15 @@ describe("isValidCreditCard", () => { expect(isValidCreditCard("4111a1111b1111c1111")).toBe(false); }); - test("when the mask uses characters other than spaces and hyphens", () => { + test("when the mask uses characters outside the interchangeable set", () => { expect(isValidCreditCard("(41)11-1111 1111 1111")).toBe(false); + expect(isValidCreditCard("4111,1111,1111,1111")).toBe(false); + expect(isValidCreditCard("4111_1111_1111_1111")).toBe(false); + }); + + test("when a mask character is not between two digits", () => { + expect(isValidCreditCard("-4111111111111111")).toBe(false); + expect(isValidCreditCard("4111111111111111.")).toBe(false); }); test("when it is null", () => { @@ -139,6 +172,10 @@ describe("isValidCreditCard", () => { test("should accept exactly one Luhn check digit for any base", () => { fc.assert( fc.property(fc.stringMatching(/^[0-9]{11,18}$/), (base) => { + // A base of a single repeated digit can have its one Luhn candidate rejected as a + // repeated-digit PAN, so it is left to the literal tests above. + fc.pre(new Set(base).size > 1); + const accepted = LUHN_DIGITS.filter((digit) => isValidCreditCard(`${base}${digit}`)); expect(accepted.length).toBe(1); @@ -146,11 +183,11 @@ describe("isValidCreditCard", () => { ); }); - test("should ignore the spaces and hyphens between the digits", () => { + test("should ignore any of the mask characters between the digits", () => { fc.assert( fc.property( fc.stringMatching(/^[0-9]{12,19}$/), - fc.constantFrom(" ", "-"), + fc.constantFrom(" ", "-", ".", "/"), (card, separator) => { const masked = card.replaceAll(/(\d{4})(?=\d)/g, `$1${separator}`); diff --git a/src/is-valid-credit-card/is-valid-credit-card.ts b/src/is-valid-credit-card/is-valid-credit-card.ts index 02c974d6..335f871f 100644 --- a/src/is-valid-credit-card/is-valid-credit-card.ts +++ b/src/is-valid-credit-card/is-valid-credit-card.ts @@ -1,21 +1,31 @@ import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; +import { isRepeatedDigits } from "../_internals/is-repeated-digits/is-repeated-digits"; import { mod10 } from "../_internals/mod10/mod10"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; import { MAX_LENGTH, MIN_LENGTH } from "./constants"; -const FORMAT_REGEX = /^\d+(?:[ -]+\d+)*$/; +const FORMAT_REGEX = /^\d+(?:[\s.\-/]+\d+)*$/; /** * Validates a payment card number (crédito ou débito) using the Luhn algorithm. * - * Accepts the usual mask characters (spaces and hyphens) between digits, a run of them included, - * so `"4111 - 1111 - 1111 - 1111"` reads as the same PAN, and whitespace around the value; any + * Accepts the usual mask characters (whitespace, `.`, `-` and `/`, the interchangeable set + * `isValidCpf` and `isValidCnpj` accept) between digits, a run of them included, so + * `"4111 - 1111 - 1111 - 1111"` reads as the same PAN, and whitespace around the value; any * other character makes the value invalid, so `"4111a1111b1111c1111"` is rejected instead of - * being read as `"4111111111111111"`. Only checks the digit count (12 to 19: 12 is + * being read as `"4111111111111111"`. They are accepted between any two digits rather than at + * fixed positions: the printed grouping of a PAN changes with the brand (4-4-4-4 for Visa and + * Mastercard, 4-6-5 for American Express, 4-6-4 for Diners Club), so there is no single layout + * to pin them to. Only checks the digit count (12 to 19: 12 is * the de-facto industry minimum PAN length, e.g. Maestro, and ISO/IEC 7812-1 caps the PAN at 19) * and the Luhn check digit; it performs no brand detection (Visa, Mastercard, Amex...), issuer * range lookup or expiration/CVV checks. * + * A value whose digits are all the same (`"0000000000000000"`) is rejected even when it passes + * the Luhn check, as every other validator of this package rejects a repeated-digit document + * (`isValidCpf("00000000000")`, `isValidCns`, `isValidCaepf`, `isValidCei`): no issuer hands out + * such a PAN, and it is what a placeholder or a zero-filled field looks like. + * * A number is only accepted when it is a non-negative safe integer: a card number above * `Number.MAX_SAFE_INTEGER` (2^53 - 1, 16 digits) has already been rounded to a different * number by the time it arrives, and a negative one is not a PAN, so both are rejected rather @@ -31,14 +41,19 @@ const FORMAT_REGEX = /^\d+(?:[ -]+\d+)*$/; * isValidCreditCard("378282246310005"); // true (American Express test number) * isValidCreditCard("4111 1111 1111 1111"); // true (spaced mask) * isValidCreditCard("4111 - 1111 - 1111 - 1111"); // true (a run of separators between the digits) + * isValidCreditCard("4111.1111/1111-1111"); // true (any of the mask characters) * isValidCreditCard("4111111111111112"); // false (bad check digit) + * isValidCreditCard("0000000000000000"); // false (every digit the same, though the Luhn check passes) * isValidCreditCard("4111a1111b1111c1111"); // false (letters between the digits) * isValidCreditCard("123456789"); // false (too short) * isValidCreditCard(4111111111111111111); // false (above 2^53 - 1, pass it as a string) * ``` * * ISO/IEC 7812-1 (issuer identification numbers) caps the PAN at 19 digits but sets no - * minimum; the 12-digit floor here is the de-facto industry minimum (e.g. Maestro). + * minimum; the 12-digit floor here is the de-facto industry minimum (e.g. Maestro). The ISO + * catalogue page sits behind a bot filter and answers HTTP 403 to every non-browser client, so + * it has to be opened in a browser, where it renders the standard's paywalled abstract rather + * than its text. * * @see Official: https://www.iso.org/standard/70484.html */ @@ -51,6 +66,8 @@ export const isValidCreditCard = (value: string | number): boolean => { if (digits.length < MIN_LENGTH || digits.length > MAX_LENGTH) return false; + if (isRepeatedDigits(digits)) return false; + const checkDigit = digits.charCodeAt(digits.length - 1) - 48; return mod10(digits.slice(0, -1)) === checkDigit; diff --git a/src/is-valid-csosn/constants.ts b/src/is-valid-csosn/constants.ts index 565ac087..88a8d67b 100644 --- a/src/is-valid-csosn/constants.ts +++ b/src/is-valid-csosn/constants.ts @@ -23,7 +23,8 @@ export const CSOSN_CODES = [ ] as const; /** - * Shape a CSOSN code has to be written in: the 3 digits, optionally split by a single - * whitespace or mask character. + * Shape a CSOSN code has to be written in: the bare 3 digits. Unlike the ICMS CST, whose origin + * digit is printed apart from the Tabela B pair, a CSOSN has no internal grouping anywhere it is + * printed (the NF-e carries the origin in its own `orig` field), so no separator is accepted. */ -export const CSOSN_FORMAT_REGEX = /^\d[\s.\-/]?\d[\s.\-/]?\d$/; +export const CSOSN_FORMAT_REGEX = /^\d{3}$/; diff --git a/src/is-valid-csosn/is-valid-csosn.test.ts b/src/is-valid-csosn/is-valid-csosn.test.ts index c699ebb2..3c29b543 100644 --- a/src/is-valid-csosn/is-valid-csosn.test.ts +++ b/src/is-valid-csosn/is-valid-csosn.test.ts @@ -53,6 +53,14 @@ describe("isValidCsosn", () => { expect(isValidCsosn("1--01")).toBe(false); }); + it("should return false for a code split by a separator, since a CSOSN has no printed grouping", () => { + expect(isValidCsosn("1-01")).toBe(false); + expect(isValidCsosn("1 01")).toBe(false); + expect(isValidCsosn("10.1")).toBe(false); + expect(isValidCsosn("1-0-1")).toBe(false); + expect(isValidCsosn(" 101 ")).toBe(true); + }); + it("should return false for a number that is not a non-negative safe integer", () => { expect(isValidCsosn(-101)).toBe(false); expect(isValidCsosn(10.1)).toBe(false); diff --git a/src/is-valid-csosn/is-valid-csosn.ts b/src/is-valid-csosn/is-valid-csosn.ts index a983076d..d8ccdbcb 100644 --- a/src/is-valid-csosn/is-valid-csosn.ts +++ b/src/is-valid-csosn/is-valid-csosn.ts @@ -8,12 +8,17 @@ import { CSOSN_CODES, CSOSN_FORMAT_REGEX } from "./constants"; * Accepted codes are `101, 102, 103, 201, 202, 203, 300, 400, 500, 900`, the table the * consolidated Anexo III-A of Convênio SINIEF s/nº 1970 carries. * - * A string is only read as a code when it is written in one of the documented forms: the 3 - * digits, with a single separator between them and optional surrounding whitespace. Anything - * else (`"abc101"`) is rejected instead of having its digits picked out. A number is only read + * A string is only read as a code when it is written as the bare 3 digits with optional + * surrounding whitespace. A CSOSN has no printed grouping (the NF-e carries the origin digit in + * its own `orig` field), so a separator inside it (`"1-01"`) is rejected, and so is anything + * else (`"abc101"`) instead of having its digits picked out. A number is only read * as a code when it is a non-negative safe integer, since a sign, a decimal point or a rounded * magnitude would otherwise be read as a code the caller never wrote. * + * No CSOSN code starts with a zero, the table runs from `101` to `900`, so nothing is ever + * padded here: a number and the string of the same digits are read identically, and a value + * narrower than 3 digits is not a code at all. + * * @param {string|number} value - The CSOSN code to be validated, e.g. `"101"` or `101`. * @returns {boolean} True when the code is a known CSOSN code, false otherwise. * diff --git a/src/is-valid-cst/constants.ts b/src/is-valid-cst/constants.ts index 84ea07aa..1d448951 100644 --- a/src/is-valid-cst/constants.ts +++ b/src/is-valid-cst/constants.ts @@ -9,8 +9,12 @@ * @see Official: https://www.confaz.fazenda.gov.br/legislacao/ajustes/2023/ajuste-sinief-39-23 * Ajuste SINIEF 39/23, which gave Tabela B its current wording with effect from 01.12.23. * @see Official: https://www.confaz.fazenda.gov.br/legislacao/ajustes/2024/AJ020_24 - * Ajuste SINIEF 20/24, which revoked items 12, 13, 52, 72 and 74 of Tabela B with effect from - * 09.07.24. + * Ajuste SINIEF 20/24, which struck items 12, 13, 52, 72 and 74 from Tabela B (effects from + * 09.07.24) before they ever took effect: those items sat in the inciso III of its cláusula + * segunda, whose effect the alínea "b" of the inciso I of the cláusula terceira of Ajuste SINIEF + * 39/23 had deferred to 1º de outubro de 2024, so the revocation reached them first and the codes + * were never in force. Neither ajuste uses the phrase "sem efeitos"; this is the reading of the + * two clauses, not a quotation. * @see Official: https://www.confaz.fazenda.gov.br/legislacao/ajustes/1994/aj_003_94 * Ajuste SINIEF 03/1994, which instituted the ICMS CST as the two digit code AB. * @see Official: https://www.confaz.fazenda.gov.br/legislacao/ajustes/2000/AJ_006_00 @@ -94,8 +98,10 @@ export const PIS_COFINS_CST_CODES = [ ] as const; /** - * Shape a CST code has to be written in: the 2 digits of the IPI, PIS and COFINS tables or - * the 3 digits of the ICMS form (origin digit + Tabela B code), optionally split by a - * single whitespace or mask character, the way documents print the origin apart ("0 10"). + * Shape a CST code has to be written in: the 2 digits of the IPI, PIS and COFINS tables, or + * the 3 digits of the ICMS form (origin digit + Tabela B code) with an optional single + * whitespace or mask character after the origin digit, the way documents print the origin + * apart ("0 10"). That is the only boundary a printed CST has: the Tabela B code is a single + * two digit code, so a separator inside it, or a trailing one, is not a CST. */ -export const CST_FORMAT_REGEX = /^\d[\s.\-/]?\d[\s.\-/]?\d?$/; +export const CST_FORMAT_REGEX = /^(?:\d{2}|\d[\s.\-/]?\d{2})$/; diff --git a/src/is-valid-cst/is-valid-cst.test.ts b/src/is-valid-cst/is-valid-cst.test.ts index 7a861ffd..d6ac8d99 100644 --- a/src/is-valid-cst/is-valid-cst.test.ts +++ b/src/is-valid-cst/is-valid-cst.test.ts @@ -33,7 +33,7 @@ describe("isValidCst", () => { expect(isValidCst("061", { tax: "icms" })).toBe(true); }); - it("should return false for the codes Ajuste SINIEF 20/24 revoked (12, 13, 52, 72 and 74)", () => { + it("should return false for the codes Ajuste SINIEF 39/23 added with deferred effect and Ajuste SINIEF 20/24 struck before they took effect (12, 13, 52, 72 and 74)", () => { expect(isValidCst("012", { tax: "icms" })).toBe(false); expect(isValidCst("013", { tax: "icms" })).toBe(false); expect(isValidCst("052", { tax: "icms" })).toBe(false); @@ -80,14 +80,43 @@ describe("isValidCst", () => { }); }); - it("should return false for an unknown tax", () => { - // @ts-expect-error not a valid tax - expect(isValidCst("00", { tax: "iss" })).toBe(false); + describe("unknown tax", () => { + it("should fall back to the default and check every table, as every other scalar option does", () => { + // @ts-expect-error not a valid tax + expect(isValidCst("000", { tax: "nope" })).toBe(true); + // @ts-expect-error not a valid tax + expect(isValidCst("00", { tax: "iss" })).toBe(true); + // @ts-expect-error not a valid tax + expect(isValidCst("07", { tax: "iss" })).toBe(true); + }); + + it("should still reject a code that exists in no table", () => { + // @ts-expect-error not a valid tax + expect(isValidCst("999", { tax: "iss" })).toBe(false); + }); + + it("should fall back to the default when the tax is not a string", () => { + // @ts-expect-error not a valid tax + expect(isValidCst("07", { tax: 1 })).toBe(true); + // @ts-expect-error not a valid tax + expect(isValidCst("07", { tax: null })).toBe(true); + }); + + it("should fall back to the default for a prototype chain key", () => { + // @ts-expect-error not a valid tax + expect(isValidCst("07", { tax: "__proto__" })).toBe(true); + // @ts-expect-error not a valid tax + expect(isValidCst("07", { tax: "toString" })).toBe(true); + }); }); - it("should return false for an unknown tax even when the code is a valid pis/cofins code", () => { - // @ts-expect-error not a valid tax - expect(isValidCst("07", { tax: "iss" })).toBe(false); + it("should consult only the given table, never the other three", () => { + expect(isValidCst("00", { tax: "icms" })).toBe(false); + expect(isValidCst("06", { tax: "ipi" })).toBe(false); + expect(isValidCst("00", { tax: "pis" })).toBe(false); + expect(isValidCst("00", { tax: "cofins" })).toBe(false); + expect(isValidCst("00")).toBe(true); + expect(isValidCst("06")).toBe(true); }); describe("without options (tax omitted)", () => { @@ -130,6 +159,35 @@ describe("isValidCst", () => { expect(isValidCst("00", "foo")).toBe(false); }); + describe("padding", () => { + it("should read a single digit as the three digit icms form, as a number or as a string", () => { + expect(isValidCst(0, { tax: "icms" })).toBe(true); + expect(isValidCst("0", { tax: "icms" })).toBe(true); + expect(isValidCst("000", { tax: "icms" })).toBe(true); + expect(isValidCst(2, { tax: "icms" })).toBe(true); + expect(isValidCst("2", { tax: "icms" })).toBe(true); + }); + + it("should agree between a number and a string when the tax is omitted", () => { + expect(isValidCst(0)).toBe(true); + expect(isValidCst("0")).toBe(true); + expect(isValidCst(9)).toBe(false); + expect(isValidCst("9")).toBe(false); + }); + + it("should leave a two digit Tabela B code as written, never padding it to three", () => { + expect(isValidCst(49, { tax: "ipi" })).toBe(true); + expect(isValidCst("49", { tax: "ipi" })).toBe(true); + expect(isValidCst(49, { tax: "icms" })).toBe(false); + expect(isValidCst("00", { tax: "ipi" })).toBe(true); + }); + + it("should trim a single digit before padding it", () => { + expect(isValidCst(" 0 ", { tax: "icms" })).toBe(true); + expect(isValidCst(" 0 ")).toBe(true); + }); + }); + it("should return false for an empty string", () => { expect(isValidCst("", { tax: "icms" })).toBe(false); }); @@ -144,15 +202,30 @@ describe("isValidCst", () => { expect(isValidCst(undefined, { tax: "icms" })).toBe(false); }); - it("should accept a single separator between the digits and surrounding whitespace", () => { + it("should accept a single separator after the origin digit and surrounding whitespace", () => { expect(isValidCst(" 1-10 ", { tax: "icms" })).toBe(true); expect(isValidCst("0 10", { tax: "icms" })).toBe(true); + expect(isValidCst("0.10", { tax: "icms" })).toBe(true); + expect(isValidCst("0/10", { tax: "icms" })).toBe(true); }); it("should return false when more than one separator sits between two digits", () => { expect(isValidCst("1--10", { tax: "icms" })).toBe(false); }); + it("should return false when a separator does not sit right after the origin digit", () => { + expect(isValidCst("00-", { tax: "icms" })).toBe(false); + expect(isValidCst("0-0", { tax: "icms" })).toBe(false); + expect(isValidCst("11-0", { tax: "icms" })).toBe(false); + expect(isValidCst("0.0", { tax: "icms" })).toBe(false); + expect(isValidCst("4-9", { tax: "ipi" })).toBe(false); + expect(isValidCst("00-")).toBe(false); + expect(isValidCst("0-0")).toBe(false); + expect(isValidCst("11-0")).toBe(false); + expect(isValidCst("0.0")).toBe(false); + expect(isValidCst("4-9")).toBe(false); + }); + it("should return false for a string that is not a documented form", () => { expect(isValidCst("abc110", { tax: "icms" })).toBe(false); }); diff --git a/src/is-valid-cst/is-valid-cst.ts b/src/is-valid-cst/is-valid-cst.ts index 411c1c22..9b8226b7 100644 --- a/src/is-valid-cst/is-valid-cst.ts +++ b/src/is-valid-cst/is-valid-cst.ts @@ -1,7 +1,14 @@ import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; +import { padLookupCode } from "../_internals/pad-lookup-code/pad-lookup-code"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; import { CST_FORMAT_REGEX, ICMS_CST_CODES, IPI_CST_CODES, PIS_COFINS_CST_CODES } from "./constants"; +/** Width of the ICMS form, the widest a CST is printed with: 1 origin digit plus a Tabela B code. */ +const CST_LENGTH = 3; + +/** Width of a bare Tabela B code, the narrowest documented form a CST is written in. */ +const TABELA_B_LENGTH = 2; + /** * Options for `isValidCst`. */ @@ -9,23 +16,30 @@ export type IsValidCstOptions = { /** * The tax whose CST (Código de Situação Tributária) table the value is checked against. * Omit it to accept a code that exists in any of the four tables (`icms`, `ipi`, `pis`, - * `cofins`). + * `cofins`); a value outside those four falls back to that same default at runtime. */ tax?: "icms" | "ipi" | "pis" | "cofins"; }; +type CstTax = NonNullable; + const isValidIcmsCst = (digits: string): boolean => digits.charAt(0) <= "8" && (ICMS_CST_CODES as readonly string[]).includes(digits.slice(1)); -const isValidForTax = (digits: string, tax: "icms" | "ipi" | "pis" | "cofins"): boolean => { - if (tax === "icms") return isValidIcmsCst(digits); - if (tax === "ipi") return (IPI_CST_CODES as readonly string[]).includes(digits); +const isValidIpiCst = (digits: string): boolean => + (IPI_CST_CODES as readonly string[]).includes(digits); - if (tax === "pis" || tax === "cofins") { - return (PIS_COFINS_CST_CODES as readonly string[]).includes(digits); - } +const isValidPisCofinsCst = (digits: string): boolean => + (PIS_COFINS_CST_CODES as readonly string[]).includes(digits); - return false; +const isKnownTax = (tax: unknown): tax is CstTax => + tax === "icms" || tax === "ipi" || tax === "pis" || tax === "cofins"; + +const isValidForTax = (digits: string, tax: CstTax): boolean => { + if (tax === "icms") return isValidIcmsCst(digits); + if (tax === "ipi") return isValidIpiCst(digits); + + return isValidPisCofinsCst(digits); }; /** @@ -42,17 +56,29 @@ const isValidForTax = (digits: string, tax: "icms" | "ipi" | "pis" | "cofins"): * 52, 53, 54, 55, 56, 60, 61, 62, 63, 64, 65, 66, 67, 70, 71, 72, 73, 74, 75, 98, 99`. * * `options.tax` is optional. When it is omitted, the code is valid as long as it exists in any - * one of the four tables above; when it is given, only that table is consulted. + * one of the four tables above; when it is given, only that table is consulted. A `tax` outside + * the four documented values falls back to that default instead of turning the code down, the + * way every other scalar option of this library (`version`, `type`, `style`) treats a value it + * does not know. * - * A string is only read as a code when it is written in one of the documented forms: the 2 or - * 3 digits, with a single separator between them and optional surrounding whitespace. - * Anything else (`"abc110"`) is rejected instead of having its digits picked out. A number is + * A string is only read as a code when it is written in one of the documented forms: the 2 + * digits of a Tabela B code, or the 3 digits of the ICMS form with an optional single + * separator after the origin digit, plus optional surrounding whitespace. The origin digit is + * the only boundary a printed CST has, so `"0 10"` and `"1-10"` are read while `"0-0"`, + * `"11-0"` and `"00-"` are not. Anything else (`"abc110"`) is rejected instead of having its + * digits picked out. A number is * only read as a code when it is a non-negative safe integer, since a sign, a decimal point or * a rounded magnitude would otherwise be read as a code the caller never wrote. * + * A single digit is narrower than either documented form, so it is left padded with zeros to + * the 3 digits of the ICMS form, whether it comes as a string or as a number: `0`, `"0"` and + * `"000"` are all the ICMS code `000`. A 2 digit value is already a documented form, a Tabela B + * code, and is read as written, so `isValidCst("00", { tax: "ipi" })` stays a CST-IPI check and + * a Tabela B code keeps its own two digits: `"07"`, not `7`, which is the ICMS code `007`. + * * @param {string|number} value - The CST code to be validated, e.g. `"110"`, `"0 10"` or `110`. * @param {IsValidCstOptions} [options] - The tax whose table the value is checked against. - * Checks every table when omitted. + * Checks every table when omitted or when the tax is not one of the four documented values. * @returns {boolean} True when the code is valid for the given tax (or for any tax, when * `options.tax` is omitted), false otherwise. * @@ -61,8 +87,12 @@ const isValidForTax = (digits: string, tax: "icms" | "ipi" | "pis" | "cofins"): * @see Official: https://www.confaz.fazenda.gov.br/legislacao/ajustes/2023/ajuste-sinief-39-23 * Ajuste SINIEF 39/23, which gave Tabela B its current wording with effect from 01.12.23. * @see Official: https://www.confaz.fazenda.gov.br/legislacao/ajustes/2024/AJ020_24 - * Ajuste SINIEF 20/24, which revoked items 12, 13, 52, 72 and 74 of Tabela B with effect from - * 09.07.24. + * Ajuste SINIEF 20/24, which struck items 12, 13, 52, 72 and 74 from Tabela B (effects from + * 09.07.24) before they ever took effect: those items sat in the inciso III of its cláusula + * segunda, whose effect the alínea "b" of the inciso I of the cláusula terceira of Ajuste SINIEF + * 39/23 had deferred to 1º de outubro de 2024, so the revocation reached them first and the codes + * were never in force. Neither ajuste uses the phrase "sem efeitos"; this is the reading of the + * two clauses, not a quotation. * @see Official: https://www.confaz.fazenda.gov.br/legislacao/ajustes/1994/aj_003_94 * Ajuste SINIEF 03/1994, which instituted the ICMS CST as the two digit code AB. * @see Official: https://www.confaz.fazenda.gov.br/legislacao/ajustes/2000/AJ_006_00 @@ -82,8 +112,11 @@ const isValidForTax = (digits: string, tax: "icms" | "ipi" | "pis" | "cofins"): * isValidCst("49", { tax: "pis" }); // true * isValidCst("07", { tax: "cofins" }); // true * isValidCst("99", { tax: "icms" }); // false + * isValidCst(0, { tax: "icms" }); // true (a single digit is padded to the 3 digit form, "000") + * isValidCst("0", { tax: "icms" }); // true (padded the same way a number is) * isValidCst("110"); // true (found in the icms table) * isValidCst("49"); // true (found in the ipi table) + * isValidCst("000", { tax: "nope" }); // true (an unknown tax falls back to checking every table) * isValidCst("999"); // false (not in any table) * isValidCst("abc110"); // false (not a documented form) * isValidCst(-110); // false (not a non-negative safe integer) @@ -93,16 +126,15 @@ export const isValidCst = (value: string | number, options?: IsValidCstOptions): if (!isLookupCode(value)) return false; if (options !== undefined && (options === null || typeof options !== "object")) return false; - const code = typeof value === "number" ? String(value) : value.trim(); + const trimmed = String(value).trim(); + const code = trimmed.length < TABELA_B_LENGTH ? padLookupCode(trimmed, CST_LENGTH) : trimmed; if (!CST_FORMAT_REGEX.test(code)) return false; const digits = sanitizeToDigits(code); const tax = options?.tax; - if (tax !== undefined) return isValidForTax(digits, tax); + if (isKnownTax(tax)) return isValidForTax(digits, tax); - return ( - isValidForTax(digits, "icms") || isValidForTax(digits, "ipi") || isValidForTax(digits, "pis") - ); + return isValidIcmsCst(digits) || isValidIpiCst(digits) || isValidPisCofinsCst(digits); }; diff --git a/src/is-valid-email/is-valid-email.test.ts b/src/is-valid-email/is-valid-email.test.ts index fdff24e0..78af6f08 100644 --- a/src/is-valid-email/is-valid-email.test.ts +++ b/src/is-valid-email/is-valid-email.test.ts @@ -59,6 +59,10 @@ describe("isValidEmail", () => { test("when a domain label is longer than the 63 characters WHATWG allows", () => { expect(isValidEmail(`user@${"a".repeat(64)}.com`)).toBe(false); }); + + test("when the final domain label is longer than the 63 characters WHATWG allows", () => { + expect(isValidEmail(`user@example.${"a".repeat(64)}`)).toBe(false); + }); }); describe("should return true", () => { @@ -80,6 +84,10 @@ describe("isValidEmail", () => { expect(isValidEmail(`user@${"a".repeat(63)}.com`)).toBe(true); }); + test("when the final domain label is exactly 63 characters long", () => { + expect(isValidEmail(`user@example.${"a".repeat(63)}`)).toBe(true); + }); + test("when is a valid email with special characters", () => { expect(isValidEmail("user+tag@example.co.uk")).toBe(true); }); diff --git a/src/is-valid-email/is-valid-email.ts b/src/is-valid-email/is-valid-email.ts index adbfe275..34b72112 100644 --- a/src/is-valid-email/is-valid-email.ts +++ b/src/is-valid-email/is-valid-email.ts @@ -1,5 +1,5 @@ const EMAIL_REGEX = - /^(?!\.)(?!.*\.\.)([a-z0-9_'+\-.]*)[a-z0-9_+-]@(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i; + /^(?!\.)(?!.*\.\.)([a-z0-9_'+\-.]*)[a-z0-9_+-]@(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i; /** * Validates if an email address is valid. @@ -15,10 +15,11 @@ const EMAIL_REGEX = * ``` * * The WHATWG HTML "valid e-mail address" definition is narrowed further: the local part is - * limited to letters, digits and `_'+-.`, it may not start with a dot or contain two dots in a - * row, and the domain must carry at least one dot and end in an alphabetic label of two or more - * letters. Each dotted label follows the WHATWG production `[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?`, - * so a label may neither start nor end with a hyphen nor exceed 63 characters. It is a practical + * limited to letters, digits and `_'+-.`, it may not start with a dot, end with a dot or an + * apostrophe, or contain two dots in a row, and the domain must carry at least one dot and end + * in an alphabetic label of 2 to 63 letters. Each dotted label follows the WHATWG production `[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?`, + * so a label may neither start nor end with a hyphen nor exceed 63 characters, and the final + * label is capped at the same 63 characters. It is a practical * subset of that WHATWG definition, not of IETF RFC 5322: quoted local parts and address * literals are rejected. * diff --git a/src/is-valid-iban/is-valid-iban.test.ts b/src/is-valid-iban/is-valid-iban.test.ts index e6c2d396..14738974 100644 --- a/src/is-valid-iban/is-valid-iban.test.ts +++ b/src/is-valid-iban/is-valid-iban.test.ts @@ -18,6 +18,17 @@ describe("isValidIban", () => { expect(isValidIban("BR15 0000 0000 0000 1093 2840 814P 2")).toBe(true); }); + test("for a value whose ISO 13616 groups are split by any of the mask characters", () => { + expect(isValidIban("BR15.0000.0000.0000.1093.2840.814P.2")).toBe(true); + expect(isValidIban("BR15-0000-0000-0000-1093-2840-814P-2")).toBe(true); + expect(isValidIban("BR15/0000/0000/0000/1093/2840/814P/2")).toBe(true); + expect(isValidIban("BR15.0000-0000/0000 1093 2840 814P2")).toBe(true); + }); + + test("for a value split at one group boundary only", () => { + expect(isValidIban("BR1500000000000010932840814P-2")).toBe(true); + }); + test("for a lowercase value", () => { expect(isValidIban("br1500000000000010932840814p2")).toBe(true); }); @@ -82,13 +93,20 @@ describe("isValidIban", () => { }); test("when it carries a character outside the print format", () => { - expect(isValidIban("BR1500000000000010932840814P-2")).toBe(false); - expect(isValidIban("BR15.0000.0000.0000.1093.2840.814P2")).toBe(false); - expect(isValidIban("BR1500000000000010932840814P/2")).toBe(false); + expect(isValidIban("BR1500000000000010932840814P_2")).toBe(false); + expect(isValidIban("BR15,0000,0000,0000,1093,2840,814P,2")).toBe(false); + expect(isValidIban("BR1500000000000010932840814P#2")).toBe(false); + }); + + test("when a separator falls inside a group instead of at its boundary", () => { + expect(isValidIban("BR15 000 00000 0000 1093 2840 814P 2")).toBe(false); + expect(isValidIban("BR1 50000000000001093 2840 814P 2")).toBe(false); + expect(isValidIban("BR15 0000 0000 0000 1093 2840 814 P2")).toBe(false); }); - test("when the groups are separated by more than one space", () => { + test("when the groups are separated by more than one separator", () => { expect(isValidIban("BR15 0000 0000 0000 1093 2840 814P 2")).toBe(false); + expect(isValidIban("BR15 0000 0000 0000 1093 2840 .-814P 2")).toBe(false); }); test("when it is an empty string", () => { @@ -145,11 +163,11 @@ describe("isValidIban", () => { ); }); - test("should ignore the grouping spaces and the case of an IBAN", () => { + test("should ignore the grouping separators and the case of an IBAN", () => { fc.assert( - fc.property(bodies, (body) => { + fc.property(bodies, fc.constantFrom(" ", ".", "-", "/"), (body, separator) => { const iban = findIban(body); - const grouped = iban.replaceAll(/(.{4})(?=.)/g, "$1 "); + const grouped = iban.replaceAll(/(.{4})(?=.)/g, `$1${separator}`); expect(isValidIban(grouped)).toBe(true); expect(isValidIban(grouped.toLowerCase())).toBe(true); @@ -157,6 +175,25 @@ describe("isValidIban", () => { ); }); + test("should reject a separator that falls inside an ISO 13616 group", () => { + fc.assert( + fc.property( + bodies, + fc.integer({ min: 1, max: 28 }), + fc.constantFrom(" ", ".", "-", "/"), + (body, index, separator) => { + fc.pre(index % 4 !== 0); + + const iban = findIban(body); + + expect(isValidIban(`${iban.slice(0, index)}${separator}${iban.slice(index)}`)).toBe( + false, + ); + }, + ), + ); + }); + test("should reject an IBAN of any other country", () => { fc.assert( fc.property(bodies, fc.stringMatching(/^[A-Z]{2}$/), (body, countryCode) => { diff --git a/src/is-valid-iban/is-valid-iban.ts b/src/is-valid-iban/is-valid-iban.ts index dfbbc033..634c9db1 100644 --- a/src/is-valid-iban/is-valid-iban.ts +++ b/src/is-valid-iban/is-valid-iban.ts @@ -22,14 +22,17 @@ const hasValidCheckDigits = (iban: string): boolean => { * * Only Brazilian IBANs (country code `BR`) are recognized: the field layout of the other 90+ * ISO 13616 countries is out of scope, so any non `BR` IBAN, however well formed, returns - * `false`. Accepts the usual grouping spaces and is case-insensitive. + * `false`. Accepts the usual grouping mask and is case-insensitive. * * Both accepted forms are the ones an IBAN is written in: compact, - * `"BR1500000000000010932840814P2"`, or the ISO 13616 print format, letters and digits in - * groups separated by a single space, with optional surrounding whitespace either way. Only a - * character outside letters and digits, or a separator other than a single space, makes the - * value something other than an IBAN, so `"BR1500000000000010932840814P-2"` and a double space - * are rejected instead of having the offending character stripped. + * `"BR1500000000000010932840814P2"`, or the ISO 13616 print format, letters and digits in groups + * of 4 (the last one shorter), with optional surrounding whitespace either way. The groups may be + * split by whitespace, `.`, `-` or `/`, the interchangeable mask characters `isValidCpf` and + * `isValidCnpj` accept, so `"BR1500000000000010932840814P-2"` reads as the same IBAN. Only a + * separator away from a group boundary, a run of separators (ISO 13616 prints a single one) or a + * character outside letters and digits makes the value something other than an IBAN, so + * `"BR15 0000 0000 0000 1093 2840 814P 2"` and `"BR15 000 00000 0000 1093 2840 814P 2"` are + * rejected instead of having the offending character stripped. * * The last character is the owner indicator, `1` for the first or only holder up to `9` for the * ninth and then `A` to `Z` from the tenth, per Circular BCB nº 3.625/2013 art. 2º § 1º, so a @@ -43,17 +46,23 @@ const hasValidCheckDigits = (iban: string): boolean => { * ```typescript * isValidIban("BR1500000000000010932840814P2"); // true * isValidIban("BR15 0000 0000 0000 1093 2840 814P 2"); // true (grouping spaces) + * isValidIban("BR15-0000-0000-0000-1093-2840-814P-2"); // true (any of the mask characters) * isValidIban("br1500000000000010932840814p2"); // true (case-insensitive) * isValidIban("BR1500000000000010932840814P3"); // false (bad check digits) - * isValidIban("BR1500000000000010932840814P-2"); // false (hyphens are not part of an IBAN) + * isValidIban("BR15 000 00000 0000 1093 2840 814P 2"); // false (a separator inside a group) * isValidIban("DE89370400440532013000"); // false (non Brazilian IBAN) * ``` * - * @see Official: https://www.bcb.gov.br/pre/normativos/circ/2013/pdf/circ_3625_v1_O.pdf Circular BCB nº 3.625/2013 - * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/Documents/sistema_pagamentos_brasileiro/IBAN-Guidelines_%20port.pdf Diretrizes de Implementação do IBAN no Brasil - * @see Official: https://www.iso.org/standard/81090.html ISO 13616-1:2020 (IBAN structure) - * @see Official: https://www.iso.org/standard/31531.html ISO/IEC 7064:2003 (MOD 97-10 check digit algorithm) - * @see Based on: https://www.iban.com/structure Used to cross check the Brazil IBAN example. + * @see Official: https://www.bcb.gov.br/pre/normativos/circ/2013/pdf/circ_3625_v1_O.pdf + * Circular BCB nº 3.625/2013 + * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/Documents/sistema_pagamentos_brasileiro/IBAN-Guidelines_%20port.pdf + * Diretrizes de Implementação do IBAN no Brasil + * @see Official: https://www.iso.org/standard/81090.html + * ISO 13616-1:2020 (IBAN structure) + * @see Official: https://www.iso.org/standard/31531.html + * ISO/IEC 7064:2003 (MOD 97-10 check digit algorithm) + * @see Based on: https://www.iban.com/structure + * Used to cross check the Brazil IBAN example. */ export const isValidIban = (value: string): boolean => { if (typeof value !== "string") return false; diff --git a/src/is-valid-ie/is-valid-ie.test.ts b/src/is-valid-ie/is-valid-ie.test.ts index f0598be5..659c628d 100644 --- a/src/is-valid-ie/is-valid-ie.test.ts +++ b/src/is-valid-ie/is-valid-ie.test.ts @@ -758,6 +758,65 @@ describe("isValidIe", () => { }); }); + describe("SINTEGRA worked examples", () => { + const publishedExamples: [StateCode, string][] = [ + ["AC", "01.004.823/001-12"], + ["AL", "240000048"], + ["AP", "030123459"], + ["BA", "123456-63"], + ["BA", "612345-57"], + ["BA", "1000003-06"], + ["CE", "06000001-5"], + ["ES", "999999990"], + ["GO", "10.987.654-7"], + ["MA", "120000385"], + ["MG", "062.307.904/0081"], + ["MT", "0013000001-9"], + ["PA", "15999999-5"], + ["PA", "75000002-3"], + ["PB", "06000001-5"], + ["PE", "0321418-40"], + ["PI", "012345679"], + ["PR", "123.45678-50"], + ["RN", "20.040.040-1"], + ["RN", "20.0.040.040-0"], + ["RO", "0000000062521-3"], + ["RR", "24006628-1"], + ["RR", "24001755-6"], + ["RR", "24003429-0"], + ["RR", "24001360-3"], + ["RR", "24008266-8"], + ["RR", "24006153-6"], + ["RR", "24007356-2"], + ["RR", "24005467-4"], + ["RR", "24004145-5"], + ["RR", "24001340-7"], + ["RS", "224/3658792"], + ["SC", "251.040.852"], + ["SE", "27123456-3"], + ["SP", "110.042.490.114"], + ["SP", "P-01100424.3/002"], + ["TO", "29010227836"], + ]; + + test("should accept every worked example the SINTEGRA pages print", () => { + for (const [stateCode, ie] of publishedExamples) { + expect(isValidIe(stateCode, ie)).toBe(true); + } + }); + + const derivedFromPublishedFormula: [StateCode, string][] = [ + ["AM", "99.999.999-0"], + ["MS", "280000006"], + ]; + + test("should accept the values derived from the formulas the AM and MS pages publish", () => { + for (const [stateCode, ie] of derivedFromPublishedFormula) { + expect(isValidIe(stateCode, ie)).toBe(true); + } + }); + }); + describe("state code lookup", () => { test("should not resolve properties from the prototype chain", () => { // @ts-expect-error: intentionally invalid input diff --git a/src/is-valid-ie/is-valid-ie.ts b/src/is-valid-ie/is-valid-ie.ts index 3d9f46fe..a07ffc49 100644 --- a/src/is-valid-ie/is-valid-ie.ts +++ b/src/is-valid-ie/is-valid-ie.ts @@ -15,6 +15,8 @@ import { TO_TYPES, } from "./constants"; +export type { StateCode } from "../_internals/constants/states"; + type IeValidator = (ie: string) => boolean; const checkLength = (ie: string, length: number | number[]): boolean => { @@ -518,10 +520,16 @@ const IE_VALIDATORS: Record = { * - AL: the tipo de empresa digit (third position) is not restricted to 0, 3, 5, 7 and 8. * - PE: only the current 9 digit eFisco format is accepted; the old 14 digit CACEPE format * documented on the same page is not. + * - TO: the SINTEGRA page documents only the 11 digit form, the one carrying the tipo digits in + * positions 3 and 4. The 9 digit form is also accepted, applying the same modulus 11 rule with + * weights 9 down to 2 to the first eight digits; it is 2.3.0 behavior kept for compatibility + * and no published SEFAZ-TO roteiro covers it. * - An all zero registration is accepted for every state whose published formula yields a - * check digit of 0 for it (AM, BA with 9 digits, CE, ES, MG, MT, PB, PE, PI, PR, RJ, RS, SC, - * SE, SP and TO with 9 digits), unlike isValidCpf and isValidCnpj, which reject repeated - * digits. + * check digit of 0 for it (AM, BA with 8 or 9 digits, CE, ES, MG, MT, PB, PE, PI, PR, RJ, RS, + * SC, SE, SP and TO with 9 digits), unlike isValidCpf and isValidCnpj, which reject repeated + * digits. AM is on that list through the second branch of its published formula only: the + * page's first branch, "Se Soma < 11 Então Dígito = 11 - Soma", gives 11 for an all zero + * registration, while the "resto <= 1 ⇒ 0" branch, the one implemented here, gives 0. * * @param {StateCode} stateCode - The state abbreviation (e.g., 'SP', 'RJ', 'MG') * @param {string} ie - The state registration number to validate @@ -569,6 +577,9 @@ const IE_VALIDATORS: Record = { * @see Official: http://www.sintegra.gov.br/Cad_Estados/cad_SE.html * @see Official: http://www.sintegra.gov.br/Cad_Estados/cad_SP.html * @see Official: http://www.sintegra.gov.br/Cad_Estados/cad_TO.html + * Documents only the 11 digit form, with the tipo digits 01, 02, 03 and 99 in positions 3 and 4; + * the 9 digit form the validator also accepts is not covered by this page or by any other + * published SEFAZ-TO roteiro. * @see Official: https://goias.gov.br/economia/roteiro-de-critica-da-inscricao-estadual-de-goias/ * SEFAZ-GO's roteiro de crítica, the source of the Goiás prefixes and special ranges. */ diff --git a/src/is-valid-legal-nature/constants.ts b/src/is-valid-legal-nature/constants.ts index e83e24a9..11e3bb4d 100644 --- a/src/is-valid-legal-nature/constants.ts +++ b/src/is-valid-legal-nature/constants.ts @@ -3,12 +3,17 @@ * * Generated by `node ./scripts/legal-natures.ts`. Do not edit by hand. * - * 92 of the 100 entries are the official codes from the CONCLA 2021 table; the other 8 - * (2076, 2100, 2208, 3042, 3050, 3093, 3123, 5002) are legacy codes kept for 2.3.0 - * compatibility. Code 3298 fixes an accent typo of the official PDF ("Referendária"). + * 92 of the 100 entries are the official codes from the CONCLA 2021 table; the other + * 8 (2076, 2100, 2208, 3042, 3050, 3093, 3123, 5002) are legacy codes kept for 2.3.0 + * compatibility. Separately, and unrelated to those legacy codes, the descriptions of the + * following official codes fix an accent typo of the PDF: 3298. * - * @see https://concla.ibge.gov.br/estrutura/natjur-estrutura/natureza-juridica-2021 - * @see https://concla.ibge.gov.br/images/concla/documentacao/CONCLA-TNJ2021-EstruturaDetalhada.pdf + * The CONCLA table page sits behind a bot filter and answers HTTP 403 to every non-browser + * client, so it has to be opened in a browser; the detailed structure PDF next to it is served + * normally. + * + * @see Official: https://concla.ibge.gov.br/estrutura/natjur-estrutura/natureza-juridica-2021 + * @see Official: https://concla.ibge.gov.br/images/concla/documentacao/CONCLA-TNJ2021-EstruturaDetalhada.pdf */ export const LEGAL_NATURE: Record = { "1015": "Órgão Público do Poder Executivo Federal", @@ -114,4 +119,5 @@ export const LEGAL_NATURE: Record = { "5002": "Organização Internacional e Outras Instituições Extraterritoriais", }; +/** Mask characters (hyphen, dot, whitespace) tolerated around a legal nature code. */ export const MASK_REGEX = /[-.\s]/g; diff --git a/src/is-valid-legal-nature/is-valid-legal-nature.ts b/src/is-valid-legal-nature/is-valid-legal-nature.ts index 87caded0..8417135f 100644 --- a/src/is-valid-legal-nature/is-valid-legal-nature.ts +++ b/src/is-valid-legal-nature/is-valid-legal-nature.ts @@ -18,6 +18,10 @@ import { LEGAL_NATURE, MASK_REGEX } from "./constants"; * isValidLegalNature("0000"); // false * ``` * + * The CONCLA table page sits behind a bot filter and answers HTTP 403 to every non-browser + * client, so it has to be opened in a browser; the detailed structure PDF next to it is served + * normally. + * * @see Official: https://concla.ibge.gov.br/estrutura/natjur-estrutura/natureza-juridica-2021 * @see Official: https://concla.ibge.gov.br/images/concla/documentacao/CONCLA-TNJ2021-EstruturaDetalhada.pdf */ diff --git a/src/is-valid-license-plate/is-valid-license-plate.ts b/src/is-valid-license-plate/is-valid-license-plate.ts index 09028a7d..3882bb9c 100644 --- a/src/is-valid-license-plate/is-valid-license-plate.ts +++ b/src/is-valid-license-plate/is-valid-license-plate.ts @@ -23,7 +23,16 @@ import { getFormatLicensePlate } from "../get-format-license-plate/get-format-li * isValidLicensePlate("invalid"); // false * ``` * + * The resolution's own text does not spell the sequence out: art. 2º § 2º delegates the + * technical specification to Anexo I, whose item 1.2 reads "O padrão de estampagem é composto de + * 7 (sete) caracteres alfanuméricos, em alto relevo, na sequência LLLNLNN" and whose item 1.2.1 + * reads `L` as a letter and `N` as a numeral. Art. 2º § 1º puts a single rear plate of that same + * standard on motorcycles and similar vehicles, and art. 2º § 3º describes the old `AAA-1111` + * PNU it coexists with. The annexes are published in a PDF of their own, cited below alongside + * the resolution's text. + * * @see Official: https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes/resolucao9692022.pdf + * @see Official: https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes/resolucao9692022anexos.pdf */ export const isValidLicensePlate = (value: string): boolean => getFormatLicensePlate(value) !== null; diff --git a/src/is-valid-mobile-phone/constants.ts b/src/is-valid-mobile-phone/constants.ts index 8e85f36f..63835132 100644 --- a/src/is-valid-mobile-phone/constants.ts +++ b/src/is-valid-mobile-phone/constants.ts @@ -1,2 +1,14 @@ +/** + * The first digit (N9) a Brazilian mobile access code may carry, per numbering rule. + * + * Version 1 is the pre-Resolução 749/2022 set kept for 2.3.0 compatibility. Version 2 is the + * set of art. 12, I, "a" of the resolution: `“7”, "8" e “9”: Serviço Móvel Pessoal (SMP), + * ressalvado o disposto no inciso II deste artigo`, the ressalva being art. 12, II, "a", + * `“700”: Serviço Móvel Global por Satélite (SMGS)`, a series outside the SMP. + * + * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749 + */ export const MOBILE_VALID_FIRST_NUMBERS_V1 = [6, 7, 8, 9]; -export const MOBILE_VALID_FIRST_NUMBERS_V2 = [9]; +export const MOBILE_VALID_FIRST_NUMBERS_V2 = [7, 8, 9]; + +export const MOBILE_SATELLITE_PREFIX = "700"; diff --git a/src/is-valid-mobile-phone/is-valid-mobile-phone.test.ts b/src/is-valid-mobile-phone/is-valid-mobile-phone.test.ts index 02c09361..f2a6e1a8 100644 --- a/src/is-valid-mobile-phone/is-valid-mobile-phone.test.ts +++ b/src/is-valid-mobile-phone/is-valid-mobile-phone.test.ts @@ -36,10 +36,14 @@ describe("isValidMobilePhone", () => { expect(isValidMobilePhone("+1 415 555 2671")).toBe(false); }); - test("when version 2 is requested but the first number digit is a version-1-only value (6, 7 or 8)", () => { - expect(isValidMobilePhone("11712345678", { version: 2 })).toBe(false); + test("when version 2 is requested but the first number digit is the version-1-only 6", () => { expect(isValidMobilePhone("11612345678", { version: 2 })).toBe(false); - expect(isValidMobilePhone("11812345678", { version: 2 })).toBe(false); + }); + + test("when version 2 is requested and the number is in the 700 satellite series", () => { + expect(isValidMobilePhone("11700123456", { version: 2 })).toBe(false); + expect(isValidMobilePhone("(11) 70012-3456", { version: 2 })).toBe(false); + expect(isValidMobilePhone("+55 11 70012-3456", { version: 2 })).toBe(false); }); }); @@ -49,8 +53,20 @@ describe("isValidMobilePhone", () => { expect(isValidMobilePhone("11987654321", { version: 2 })).toBe(true); }); + test("when version 2 is requested and the first number digit is 7 or 8", () => { + expect(isValidMobilePhone("11712345678", { version: 2 })).toBe(true); + expect(isValidMobilePhone("11812345678", { version: 2 })).toBe(true); + }); + + test("when version 2 is requested and the number only starts like the 700 series", () => { + expect(isValidMobilePhone("11701234567", { version: 2 })).toBe(true); + expect(isValidMobilePhone("11770012345", { version: 2 })).toBe(true); + }); + test("when is a valid mobile phone version 1", () => { expect(isValidMobilePhone("11712345678", { version: 1 })).toBe(true); + expect(isValidMobilePhone("11612345678", { version: 1 })).toBe(true); + expect(isValidMobilePhone("11700123456", { version: 1 })).toBe(true); }); test("when it carries the country code", () => { diff --git a/src/is-valid-mobile-phone/is-valid-mobile-phone.ts b/src/is-valid-mobile-phone/is-valid-mobile-phone.ts index 3ed92c84..d6afbdf7 100644 --- a/src/is-valid-mobile-phone/is-valid-mobile-phone.ts +++ b/src/is-valid-mobile-phone/is-valid-mobile-phone.ts @@ -2,13 +2,17 @@ import { PHONE_NATIONAL_MAX_LENGTH } from "../_internals/constants/phone"; import { isValidDDD } from "../_internals/is-valid-ddd/is-valid-ddd"; import { normalizePhone } from "../_internals/normalize-phone/normalize-phone"; import { type PhoneVersion } from "../is-valid-phone/is-valid-phone"; -import { MOBILE_VALID_FIRST_NUMBERS_V1, MOBILE_VALID_FIRST_NUMBERS_V2 } from "./constants"; +import { + MOBILE_SATELLITE_PREFIX, + MOBILE_VALID_FIRST_NUMBERS_V1, + MOBILE_VALID_FIRST_NUMBERS_V2, +} from "./constants"; export type { PhoneVersion } from "../is-valid-phone/is-valid-phone"; /** Options of `isValidMobilePhone`. */ export type IsValidMobilePhoneOptions = { - /** Numbering rule to enforce over the 11 digit number: `1` (default) accepts 6, 7, 8 or 9 as the first number digit, `2` requires 9. */ + /** Numbering rule to enforce over the 11 digit number: `1` (default) accepts 6, 7, 8 or 9 as the first number digit, `2` accepts 7, 8 or 9 and rejects the `700` series. */ version?: PhoneVersion; }; @@ -19,6 +23,8 @@ const isValidMobileFirstNumber = (value: string, version?: PhoneVersion): boolea return MOBILE_VALID_FIRST_NUMBERS_V1.includes(firstDigit); } + if (value.startsWith(MOBILE_SATELLITE_PREFIX, 2)) return false; + return MOBILE_VALID_FIRST_NUMBERS_V2.includes(firstDigit); }; @@ -31,7 +37,8 @@ const isValidMobileFirstNumber = (value: string, version?: PhoneVersion): boolea * The `version` option controls which mobile numbering rule is enforced: * - `1` (default): accepts the legacy 11-digit format, whose first number digit * (right after the DDD) may be 6, 7, 8 or 9. - * - `2`: enforces the current format, whose first number digit must be 9. + * - `2`: enforces the current format, whose first number digit must be 7, 8 or 9 and whose + * `700` series is left out. * * @param {string} value - The phone number to validate. * @param {IsValidMobilePhoneOptions} options - Optional validation options. @@ -43,18 +50,20 @@ const isValidMobileFirstNumber = (value: string, version?: PhoneVersion): boolea * isValidMobilePhone("(11) 98765-4321"); // true (accepts both v1 and v2) * isValidMobilePhone("11987654321", { version: 2 }); // true * isValidMobilePhone("11712345678", { version: 1 }); // true - * isValidMobilePhone("11712345678", { version: 2 }); // false (v2 requires 9 as the first digit) + * isValidMobilePhone("11712345678", { version: 2 }); // true (7 is SMP as well) + * isValidMobilePhone("11612345678", { version: 2 }); // false (6 is Reserva Técnica) + * isValidMobilePhone("11700123456", { version: 2 }); // false (the 700 series is satellite) * isValidMobilePhone("+55 11 98765-4321"); // true * ``` * * `version: 1` (the default) is the pre-Resolução 749/2022 rule, which also accepts a leading - * 6, kept for 2.3.0 compatibility. `version: 2` enforces only 9, a stricter subset of the - * resolution's art. 12 I, which places 7, 8 and 9 in Serviço Móvel Pessoal (SMP). + * 6, kept for 2.3.0 compatibility. `version: 2` enforces art. 12, I, "a" of the resolution, + * `“7”, "8" e “9”: Serviço Móvel Pessoal (SMP), ressalvado o disposto no inciso II deste + * artigo`, so 6 is Reserva Técnica and is rejected. * - * `version: 1` also does not carve out the `700` prefix, which art. 12 II reserves for the - * Serviço Móvel Global por Satélite rather than SMP, so `isValidMobilePhone("11700123456")` is - * `true` for a number outside SMP. `version: 2` rejects it, along with every other first digit - * that is not 9. + * That ressalva is art. 12, II, "a", `“700”: Serviço Móvel Global por Satélite (SMGS)`: the + * `700` series is not SMP, so `version: 2` rejects `isValidMobilePhone("11700123456")`. + * `version: 1` does not carve the series out and accepts it, for 2.3.0 compatibility. * * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749 */ diff --git a/src/is-valid-ncm/is-valid-ncm.test.ts b/src/is-valid-ncm/is-valid-ncm.test.ts index e443f845..2c044444 100644 --- a/src/is-valid-ncm/is-valid-ncm.test.ts +++ b/src/is-valid-ncm/is-valid-ncm.test.ts @@ -24,8 +24,14 @@ describe("isValidNcm", () => { expect(isValidNcm("0101.21.00")).toBe(true); }); - it("should return false for a number that lost a leading zero (1012100 is not 01012100)", () => { - expect(isValidNcm(1_012_100)).toBe(false); + it("should pad a value to eight digits, as a number or as a string (1012100 is 01012100)", () => { + expect(isValidNcm(1_012_100)).toBe(true); + expect(isValidNcm("1012100")).toBe(true); + }); + + it("should not pad a masked value, which already carries its separators", () => { + expect(isValidNcm("101.21.00")).toBe(false); + expect(isValidNcm("0101.21.00")).toBe(true); }); it("should validate an NCM code with surrounding whitespace", () => { @@ -36,7 +42,7 @@ describe("isValidNcm", () => { expect(isValidNcm("12345678")).toBe(false); }); - it("should return false when the digit count is not eight", () => { + it("should return false for a padded short value no code carries and for a wider value", () => { expect(isValidNcm("2203000")).toBe(false); expect(isValidNcm("220300000")).toBe(false); }); @@ -77,9 +83,6 @@ describe("isValidNcm", () => { describe("properties", () => { const codeArbitrary = fc.constantFrom(...NCM_CODES); - const nonZeroLeadingCodeArbitrary = fc.constantFrom( - ...NCM_CODES.filter((code) => !code.startsWith("0")), - ); test("should never throw, regardless of the input", () => { expectNeverThrows(isValidNcm, anyGarbage); @@ -96,10 +99,13 @@ describe("isValidNcm", () => { ); }); - test("should validate every known code without a leading zero when given as a number", () => { + test("should validate every known code written without its leading zeros", () => { fc.assert( - fc.property(nonZeroLeadingCodeArbitrary, (code) => { + fc.property(codeArbitrary, (code) => { + const unpadded = String(Number(code)); + expect(isValidNcm(Number(code))).toBe(true); + expect(isValidNcm(unpadded)).toBe(true); }), ); }); diff --git a/src/is-valid-ncm/is-valid-ncm.ts b/src/is-valid-ncm/is-valid-ncm.ts index 9a8859f7..ab08009c 100644 --- a/src/is-valid-ncm/is-valid-ncm.ts +++ b/src/is-valid-ncm/is-valid-ncm.ts @@ -1,7 +1,10 @@ import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; +import { padLookupCode } from "../_internals/pad-lookup-code/pad-lookup-code"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; import { NCM_CODES, NCM_FORMAT_REGEX } from "./constants"; +const NCM_LENGTH = 8; + let cache: Set | undefined; const getCache = (): Set => { @@ -19,9 +22,10 @@ const getCache = (): Set => { * since a sign, a decimal point or a rounded magnitude would otherwise be read as a code the * caller never wrote. * - * A bare `number` input cannot represent a code that starts with `0` (the leading zero is - * lost), so a numeric NCM code starting with `0` must be passed as a string to validate - * correctly. + * An NCM code is always 8 digits and its leading zeros are part of it, so a value written as + * bare digits is left padded with zeros to 8 whether it comes as a string or as a number: + * `1012100`, `"1012100"` and `"01012100"` are the same code. A masked value already carries its + * separators and is read as written. * * @param {string|number} value - The NCM code to be validated, with or without the * `NNNN.NN.NN` mask. @@ -31,6 +35,8 @@ const getCache = (): Set => { * ```typescript * isValidNcm("0101.21.00"); // true * isValidNcm("01012100"); // true + * isValidNcm(1012100); // true (padded to 8 digits, so this is "01012100") + * isValidNcm("1012100"); // true (padded to 8 digits, so this is "01012100") * isValidNcm("00000000"); // false * isValidNcm("abc01012100"); // false (not a documented form) * isValidNcm(-84713012); // false (not a non-negative safe integer) @@ -41,7 +47,7 @@ const getCache = (): Set => { export const isValidNcm = (value: string | number): boolean => { if (!isLookupCode(value)) return false; - const code = typeof value === "number" ? String(value) : value.trim(); + const code = padLookupCode(value, NCM_LENGTH); if (!NCM_FORMAT_REGEX.test(code)) return false; diff --git a/src/is-valid-nfe-key/is-valid-nfe-key.test.ts b/src/is-valid-nfe-key/is-valid-nfe-key.test.ts index ebf3790d..e2b744ce 100644 --- a/src/is-valid-nfe-key/is-valid-nfe-key.test.ts +++ b/src/is-valid-nfe-key/is-valid-nfe-key.test.ts @@ -56,6 +56,17 @@ describe("isValidNfeKey", () => { expect(isValidNfeKey("3517 0458 7165 2300 0119 5500 1000 0000 1210 0012 3458")).toBe(true); }); + test("when the printed groups of 4 are split by any of the mask characters", () => { + expect(isValidNfeKey("3517.0458.7165.2300.0119.5500.1000.0000.1210.0012.3458")).toBe(true); + expect(isValidNfeKey("3517-0458-7165-2300-0119-5500-1000-0000-1210-0012-3458")).toBe(true); + expect(isValidNfeKey("3517/0458/7165/2300/0119/5500/1000/0000/1210/0012/3458")).toBe(true); + }); + + test("when the mask characters are mixed and a run of them separates two groups", () => { + expect(isValidNfeKey("3517.0458-7165/2300 0119 5500 1000 0000 1210 0012 3458")).toBe(true); + expect(isValidNfeKey("3517 - 0458 7165 2300 0119 5500 1000 0000 1210 0012 3458")).toBe(true); + }); + test("when it has the NFe prefix and a whitespace mask combined", () => { expect(isValidNfeKey("NFe 3512 0859 5972 4500 0190 5500 0000 0095 8317 1004 0056")).toBe( true, @@ -112,6 +123,21 @@ describe("isValidNfeKey", () => { expect(isValidNfeKey(`${VALID_B}9`)).toBe(false); }); + test("when it has whole groups of 4 digits but not the 44 of a key", () => { + expect(isValidNfeKey(VALID_B.slice(0, 40))).toBe(false); + expect(isValidNfeKey(`${VALID_B}9999`)).toBe(false); + }); + + test("when a separator falls inside a printed group of 4 digits", () => { + expect(isValidNfeKey("351 70458716523000119550010000000121000123458")).toBe(false); + expect(isValidNfeKey("3517 0458 7165 2300 0119 5500 1000 0000 1210 00123 458")).toBe(false); + }); + + test("when the groups are split by a character outside the mask", () => { + expect(isValidNfeKey("3517#0458#7165#2300#0119#5500#1000#0000#1210#0012#3458")).toBe(false); + expect(isValidNfeKey("3517,0458,7165,2300,0119,5500,1000,0000,1210,0012,3458")).toBe(false); + }); + test("when the cUF is not a valid IBGE UF code", () => { expect(isValidNfeKey(`00${VALID_B.slice(2)}`)).toBe(false); }); @@ -185,7 +211,7 @@ describe("isValidNfeKey", () => { expected: false, }, { - name: "model 67, the CT-e OS of the Ajuste SINIEF 09/07", + name: "model 67, the CT-e OS instituted by the cláusula primeira of the Ajuste SINIEF 36/19", key: "35170458716523000119670010000000121000123458", expected: true, }, @@ -224,14 +250,35 @@ describe("isValidNfeKey", () => { ); }); - test("should ignore whitespace anywhere between the digits", () => { + test("should ignore any mask character placed at a printed group boundary", () => { + fc.assert( + fc.property( + fc.integer({ min: 1, max: 10 }), + fc.constantFrom(" ", ".", "-", "/"), + (group, separator) => { + const index = group * 4; + const masked = `${NFE_KEY.slice(0, index)}${separator}${NFE_KEY.slice(index)}`; + + expect(isValidNfeKey(masked)).toBe(true); + expect(isValidNfeKey(`NFe${masked}`)).toBe(true); + }, + ), + ); + }); + + test("should reject a mask character placed anywhere but a printed group boundary", () => { fc.assert( - fc.property(fc.integer({ min: 1, max: 43 }), (index) => { - const masked = `${NFE_KEY.slice(0, index)} ${NFE_KEY.slice(index)}`; + fc.property( + fc.integer({ min: 1, max: 43 }), + fc.constantFrom(" ", ".", "-", "/"), + (index, separator) => { + fc.pre(index % 4 !== 0); - expect(isValidNfeKey(masked)).toBe(true); - expect(isValidNfeKey(`NFe${masked}`)).toBe(true); - }), + const masked = `${NFE_KEY.slice(0, index)}${separator}${NFE_KEY.slice(index)}`; + + expect(isValidNfeKey(masked)).toBe(false); + }, + ), ); }); diff --git a/src/is-valid-nfe-key/is-valid-nfe-key.ts b/src/is-valid-nfe-key/is-valid-nfe-key.ts index b0edffe7..8d837a91 100644 --- a/src/is-valid-nfe-key/is-valid-nfe-key.ts +++ b/src/is-valid-nfe-key/is-valid-nfe-key.ts @@ -1,4 +1,4 @@ -import { parseNfeKey } from "../parse-nfe-key/parse-nfe-key"; +import { getNfeKeyInfo } from "../get-nfe-key-info/get-nfe-key-info"; /** * Validates a DF-e (Documento Fiscal eletrônico) access key (chave de acesso). @@ -7,9 +7,12 @@ import { parseNfeKey } from "../parse-nfe-key/parse-nfe-key"; * (65), CT-e (57), MDF-e (58), CT-e OS (67, the Conhecimento de Transporte Eletrônico para * Outros Serviços), GTV-e (64, the CT-e Guia de Transporte de Valores), BP-e (63), NF3e (66) * and NFCom (62). The CF-e-SAT (59) is out: its 44 position "chave de consulta" is composed - * differently. Accepts whitespace between digit groups (the common display mask) and the `NFe`, - * `CTe`, `MDFe`, `BPe`, `NF3e` and `NFCom` prefixes found in the `Id` attribute of the - * document's XML (e.g. `Id="NFe3517...`), which are stripped before validation. + * differently. The 44 digits may be split into the printed groups of 4 by whitespace, `.`, `-` + * or `/`, a run of them between two groups included, the same mask rule `isValidCpf` and + * `isValidCnpj` follow; a separator inside a group of 4, or any other character, is rejected + * instead of being stripped. The `NFe`, `CTe`, `MDFe`, `BPe`, `NF3e` and `NFCom` prefixes found + * in the `Id` attribute of the document's XML (e.g. `Id="NFe3517...`) are stripped before that + * check, with any whitespace between the prefix and the first group. * * The key is `cUF(2) AAMM(4) CNPJ/CPF(14) mod(2) serie(3) nNF(9) tpEmis(1) cNF(8) cDV(1)`, with * NFCom and NF3e spending position 36 on `nSiteAutoriz` and leaving 7 digits for `cNF`. @@ -31,9 +34,16 @@ import { parseNfeKey } from "../parse-nfe-key/parse-nfe-key"; * @see Official: https://www.confaz.fazenda.gov.br/legislacao/arquivo-manuais/moc7-visao-geral.pdf * Manual de Orientação do Contribuinte (MOC) NF-e, "chave de acesso". * @see Official: https://www.confaz.fazenda.gov.br/legislacao/ajustes/2007/AJ_009_07 - * Ajuste SINIEF 09/07, cláusula primeira, § 3.º, II, "b": the CT-e OS, modelo 67. - * @see Official: https://www.cte.fazenda.gov.br/portal/listaManuais.aspx?tipoConteudo=manuais - * CT-e MOC 4.00, Anexo I: modelo 64 (GTV-e) and the `tpEmis` domains D19, D27 and D15. + * Ajuste SINIEF 09/07, cláusula primeira, caput: the CT-e, modelo 57. + * @see Official: https://www.confaz.fazenda.gov.br/legislacao/ajustes/2019/AJ036_19 + * Ajuste SINIEF 36/19, cláusula primeira: the CT-e OS, modelo 67. + * @see Official: https://www.confaz.fazenda.gov.br/legislacao/ajustes/2020/ajuste-sinief-03-20 + * Ajuste SINIEF 03/20, cláusula primeira: the GTV-e, modelo 64. + * @see Official: https://dfe-portal.svrs.rs.gov.br/CTE/Documentos + * CT-e MOC 4.00, Anexo I ("MOC CTe 4.00 Anexo I - Leiaute e Regras de Validação"): the `tpEmis` + * domains D19, D27 and D15. Published by the SVRS dfe-portal, like the BP-e, NF3e and NFCom + * manuals below; the cte.fazenda.gov.br manual index answers "Sistema temporariamente + * indisponível" permanently. * @see Official: https://dfe-portal.svrs.rs.gov.br/BPE/Documentos * BP-e MOC 1.00b, Visão Geral and Anexo I: modelo 63. * @see Official: https://dfe-portal.svrs.rs.gov.br/NF3e/Documentos @@ -50,8 +60,10 @@ import { parseNfeKey } from "../parse-nfe-key/parse-nfe-key"; * isValidNfeKey("35170458716523000119550010000000121000123458"); // true (NF-e, SP) * isValidNfeKey("NFe35170458716523000119550010000000121000123458"); // true (XML Id prefix) * isValidNfeKey("3517 0458 7165 2300 0119 5500 1000 0000 1210 0012 3458"); // true (masked) + * isValidNfeKey("3517.0458.7165.2300.0119.5500.1000.0000.1210.0012.3458"); // true (any of the mask characters) + * isValidNfeKey("351 70458716523000119550010000000121000123458"); // false (a separator inside a group of 4) * isValidNfeKey("99170458716523000119550010000000121000123458"); // false (invalid cUF) * isValidNfeKey("35170458716523000119010010000000121000123450"); // false (invalid mod) * ``` */ -export const isValidNfeKey = (value: string): boolean => parseNfeKey(value) !== null; +export const isValidNfeKey = (value: string): boolean => getNfeKeyInfo(value) !== null; diff --git a/src/is-valid-passport/is-valid-passport.test.ts b/src/is-valid-passport/is-valid-passport.test.ts index 7b8fb3a3..6385db45 100644 --- a/src/is-valid-passport/is-valid-passport.test.ts +++ b/src/is-valid-passport/is-valid-passport.test.ts @@ -80,6 +80,13 @@ describe("isValidPassport", () => { }); }); +describe("isValidPassport with an array of characters", () => { + test("should reject it instead of reading it as the joined string", () => { + // @ts-expect-error: intentionally invalid input + expect(isValidPassport(["A", "B", "1", "2", "3", "4", "5", "6"])).toBe(false); + }); +}); + describe("isValidPassport types", () => { test("should take a string or number and return a boolean", () => { expectTypeOf(isValidPassport).parameter(0).toEqualTypeOf(); diff --git a/src/is-valid-passport/is-valid-passport.ts b/src/is-valid-passport/is-valid-passport.ts index 188c9119..1721497f 100644 --- a/src/is-valid-passport/is-valid-passport.ts +++ b/src/is-valid-passport/is-valid-passport.ts @@ -22,7 +22,11 @@ import { PASSPORT_REGEX } from "./constants"; * isValidPassport("12345678") // false * isValidPassport("DC-221345extra") // false * + * The Polícia Federal passport FAQ states the layout: "Ele é composto por duas letras - chamadas + * de 'série', e por seis dígitos subsequentes. Por exemplo: Passaporte CS265436." + * * @see Official: https://www.gov.br/pf/pt-br/assuntos/passaporte + * @see Official: https://www.gov.br/pf/pt-br/assuntos/passaporte/ajuda/duvidas_/caderneta/caderneta-numero-onde-fica-e */ export const isValidPassport = (passport: string | number): boolean => { if (typeof passport !== "string") return false; diff --git a/src/is-valid-phone/is-valid-phone.test.ts b/src/is-valid-phone/is-valid-phone.test.ts index c6b70085..49b8051e 100644 --- a/src/is-valid-phone/is-valid-phone.test.ts +++ b/src/is-valid-phone/is-valid-phone.test.ts @@ -60,6 +60,11 @@ describe("isValidPhone", () => { expect(isValidPhone("08001234567", { accept: [] })).toBe(false); }); + test("when version 2 rejects the mobile number", () => { + expect(isValidPhone("11612345678", { version: 2 })).toBe(false); + expect(isValidPhone("11700123456", { version: 2 })).toBe(false); + }); + test("when the kind is not accepted", () => { expect(isValidPhone("11987654321", { accept: ["landline"] })).toBe(false); expect(isValidPhone("1130000000", { accept: ["mobile"] })).toBe(false); @@ -72,6 +77,8 @@ describe("isValidPhone", () => { test("when is a valid mobile phone version 2", () => { expect(isValidPhone("(11) 98765-4321")).toBe(true); expect(isValidPhone("11987654321", { version: 2 })).toBe(true); + expect(isValidPhone("11712345678", { version: 2 })).toBe(true); + expect(isValidPhone("11812345678", { version: 2 })).toBe(true); }); test("when is a valid landline phone", () => { @@ -151,6 +158,13 @@ describe("isValidPhone", () => { }); }); +describe("isValidPhone with an array of characters", () => { + test("should reject it instead of reading it as the joined string", () => { + // @ts-expect-error: intentionally invalid input + expect(isValidPhone("11987654321".match(/\d/g))).toBe(false); + }); +}); + describe("isValidPhone types", () => { test("should take a string, optional options, and return a boolean", () => { expectTypeOf(isValidPhone).parameter(0).toEqualTypeOf(); diff --git a/src/is-valid-phone/is-valid-phone.ts b/src/is-valid-phone/is-valid-phone.ts index a0ae3ea3..363be4fe 100644 --- a/src/is-valid-phone/is-valid-phone.ts +++ b/src/is-valid-phone/is-valid-phone.ts @@ -9,7 +9,7 @@ import { isValidMobilePhone } from "../is-valid-mobile-phone/is-valid-mobile-pho import { isValidServicePhone } from "../is-valid-service-phone/is-valid-service-phone"; import { DEFAULT_ACCEPT } from "./constants"; -/** The Brazilian mobile numbering rule to enforce over the 11 digit number: `1` the legacy one, `2` the current one. */ +/** The Brazilian mobile numbering rule to enforce over the 11 digit number: `1` the legacy one (6, 7, 8 or 9), `2` the current one (7, 8 or 9, without the `700` series). */ export type PhoneVersion = 1 | 2; /** The kinds of Brazilian phone number `isValidPhone` can accept. */ @@ -33,6 +33,10 @@ export type IsValidPhoneOptions = { * `["mobile", "landline"]`, i.e. geographic numbers only. Add `"service"` to also accept the * non-geographic numbers recognized by `isValidServicePhone`; pass `[]` to accept none. * + * `options.version` is forwarded to `isValidMobilePhone` and only affects mobile numbers: + * `1` (the default) accepts a first number digit of 6, 7, 8 or 9, and `2` the 7, 8 and 9 of + * Resolução Anatel nº 749/2022, art. 12, I, "a", minus its `700` satellite series. + * * @param {string} value - The phone number to validate. * @param {IsValidPhoneOptions} options - Optional validation options. * @param {1|2} options.version - The mobile numbering rule to enforce, see `isValidMobilePhone`. @@ -43,6 +47,8 @@ export type IsValidPhoneOptions = { * ```typescript * isValidPhone("(11) 98765-4321"); // true * isValidPhone("11987654321", { version: 2 }); // true + * isValidPhone("11712345678", { version: 2 }); // true (7 is SMP as well) + * isValidPhone("11700123456", { version: 2 }); // false (the 700 series is satellite) * isValidPhone("1130000000"); // true (landline) * isValidPhone("+55 11 98765-4321"); // true * isValidPhone("08001234567"); // false (service numbers are not accepted by default) @@ -53,8 +59,6 @@ export type IsValidPhoneOptions = { * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749 */ export const isValidPhone = (value: string, options?: IsValidPhoneOptions): boolean => { - if (typeof value !== "string") return false; - const requested = options?.accept; const accept: PhoneType[] = Array.isArray(requested) ? requested : DEFAULT_ACCEPT; diff --git a/src/is-valid-pix-key/is-valid-pix-key.test.ts b/src/is-valid-pix-key/is-valid-pix-key.test.ts index 01d560ba..eca5f1b4 100644 --- a/src/is-valid-pix-key/is-valid-pix-key.test.ts +++ b/src/is-valid-pix-key/is-valid-pix-key.test.ts @@ -4,7 +4,7 @@ import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime import { generateCnpj } from "../generate-cnpj/generate-cnpj"; import { generateCpf } from "../generate-cpf/generate-cpf"; import { generatePhone } from "../generate-phone/generate-phone"; -import { type PixKeyType, parsePixKey } from "../parse-pix-key/parse-pix-key"; +import { type PixKeyType, getPixKeyInfo } from "../get-pix-key-info/get-pix-key-info"; import { type IsValidPixKeyOptions, isValidPixKey } from "./is-valid-pix-key"; describe("isValidPixKey", () => { @@ -131,10 +131,10 @@ describe("isValidPixKey", () => { ); }); - test("should agree with parsePixKey on every value", () => { + test("should agree with getPixKeyInfo on every value", () => { fc.assert( fc.property(fc.string({ unit: "grapheme" }), (value) => { - expect(isValidPixKey(value)).toBe(parsePixKey(value) !== null); + expect(isValidPixKey(value)).toBe(getPixKeyInfo(value) !== null); }), ); }); diff --git a/src/is-valid-pix-key/is-valid-pix-key.ts b/src/is-valid-pix-key/is-valid-pix-key.ts index a49b0935..547b9bef 100644 --- a/src/is-valid-pix-key/is-valid-pix-key.ts +++ b/src/is-valid-pix-key/is-valid-pix-key.ts @@ -1,4 +1,4 @@ -import { type PixKeyType, parsePixKey } from "../parse-pix-key/parse-pix-key"; +import { type PixKeyType, getPixKeyInfo } from "../get-pix-key-info/get-pix-key-info"; /** Options of `isValidPixKey`. */ export type IsValidPixKeyOptions = { @@ -9,7 +9,7 @@ export type IsValidPixKeyOptions = { /** * Validates a Pix key (chave Pix) against the DICT key formats. * - * A value is valid when `parsePixKey` recognizes it as a CPF, a CNPJ, an e-mail address, a + * A value is valid when `getPixKeyInfo` recognizes it as a CPF, a CNPJ, an e-mail address, a * Brazilian mobile phone number or a random key (EVP), and when that kind is listed in * `options.accept`. The manual registers a "número de telefone celular", so a landline is not * a valid phone key. @@ -30,12 +30,14 @@ export type IsValidPixKeyOptions = { * ``` * * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/pix/Regulamento_Pix/II_ManualdePadroesparaIniciacaodoPix.pdf - * @see Official: https://github.com/bacen/pix-dict-api DICT (Diretório de Identificadores de - * Contas Transacionais) OpenAPI spec, key format reference. - * @see Official: https://github.com/bacen/pix-api Pix (SPI) OpenAPI spec. + * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/pix/API-DICT.html + * DICT (Diretório de Identificadores de Contas Transacionais) API specification, key format + * reference. + * @see Official: https://github.com/bacen/pix-api + * Pix (SPI) OpenAPI spec. */ export const isValidPixKey = (value: string, options?: IsValidPixKeyOptions): boolean => { - const key = parsePixKey(value); + const key = getPixKeyInfo(value); if (!key) return false; diff --git a/src/is-valid-pix-payload/is-valid-pix-payload.ts b/src/is-valid-pix-payload/is-valid-pix-payload.ts index f3203542..a0762316 100644 --- a/src/is-valid-pix-payload/is-valid-pix-payload.ts +++ b/src/is-valid-pix-payload/is-valid-pix-payload.ts @@ -1,4 +1,4 @@ -import { parsePixPayload } from "../parse-pix-payload/parse-pix-payload"; +import { getPixPayloadInfo } from "../get-pix-payload-info/get-pix-payload-info"; /** * Validates a Pix BR Code payload, the string behind a Pix QR Code and behind "Pix copia e @@ -16,14 +16,20 @@ import { parsePixPayload } from "../parse-pix-payload/parse-pix-payload"; * greater than zero, unless it is a Pix Saque BR Code, i.e. unless it carries the ISPB of the * "facilitador de serviço de saque" in sub-object 26-03 (`fss`) as §2.6 of the Pix manual * prescribes; rejecting `"0"`/`"0.00"` without `fss` is a deliberate restriction of this - * library, not a rule of the manual. + * library, not a rule of the manual. A `fss` written next to a PSP location makes the payload + * invalid: §2.7 of the Manual de Padrões para Iniciação do Pix maps the dynamic QR Code to + * exactly two sub-objects, `00` (GUI) and `25` (URL), and `fss` belongs to the static template + * of §2.6. * * The key itself is not checked against the DICT formats: the manual states a static QR Code * can be generated with a key that is not (or is no longer) registered, so use `isValidPixKey` * when that matters. * - * Payloads that carry the location in an Unreserved Template (IDs 80 to 99), as the "QR Code - * composto" of Pix Automático (Pix recorrente) does, are out of scope and reported as invalid. + * Unreserved Templates (IDs 80 to 99) are ignored. The "QR Code composto" of Pix Automático + * (Pix recorrente) writes its recurrence location in one of them: when such a payload also + * carries a payment location in 26-25, as the composite example of the Pix manual does, it is + * accepted here and read as an ordinary dynamic payload, its recurrence location dropped. Only + * a payload with no Pix template at all in IDs 26 to 51 is reported as invalid. * * @param {string} value - The BR Code payload to validate. * @returns {boolean} True if the payload is a valid Pix BR Code, false otherwise. @@ -40,7 +46,9 @@ import { parsePixPayload } from "../parse-pix-payload/parse-pix-payload"; * * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/spb_docs/ManualBRCode.pdf * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/pix/Regulamento_Pix/II_ManualdePadroesparaIniciacaodoPix.pdf - * @see Official: https://github.com/bacen/pix-api Pix (SPI) OpenAPI spec. - * @see Official: https://github.com/bacen/pix-dict-api DICT OpenAPI spec. + * @see Official: https://github.com/bacen/pix-api + * Pix (SPI) OpenAPI spec. + * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/pix/API-DICT.html + * DICT (Diretório de Identificadores de Contas Transacionais) API specification. */ -export const isValidPixPayload = (value: string): boolean => parsePixPayload(value) !== null; +export const isValidPixPayload = (value: string): boolean => getPixPayloadInfo(value) !== null; diff --git a/src/is-valid-processo-juridico/constants.ts b/src/is-valid-processo-juridico/constants.ts index 21d4482c..79bb55e5 100644 --- a/src/is-valid-processo-juridico/constants.ts +++ b/src/is-valid-processo-juridico/constants.ts @@ -2,3 +2,6 @@ export const CHECK_DIGIT_START_POSITION = 7; export const CHECK_DIGIT_LENGTH = 2; export const MOD_97_10_QUOTIENT = 97; export const MOD_97_10_SUM = 98; +export const COURT_POSITION = 13; +export const TRIBUNAL_START_POSITION = 14; +export const TRIBUNAL_LENGTH = 2; diff --git a/src/is-valid-processo-juridico/is-valid-processo-juridico.test.ts b/src/is-valid-processo-juridico/is-valid-processo-juridico.test.ts index 2b5a4941..78436ccf 100644 --- a/src/is-valid-processo-juridico/is-valid-processo-juridico.test.ts +++ b/src/is-valid-processo-juridico/is-valid-processo-juridico.test.ts @@ -1,6 +1,9 @@ import * as fc from "fast-check"; -import { PROCESSO_JURIDICO_LENGTH } from "../_internals/constants/processo-juridico"; +import { + PROCESSO_JURIDICO_LENGTH, + PROCESSO_JURIDICO_TRIBUNALS, +} from "../_internals/constants/processo-juridico"; import { anyValue, digitsOfOtherLength, maskSeparators } from "../_internals/test/arbitraries"; import { expectAlwaysReturnsType, expectRejected } from "../_internals/test/properties"; import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; @@ -47,6 +50,34 @@ describe("isValidProcessoJuridico", () => { test("when a mask separator falls outside the CNJ field boundaries", () => { expect(isValidProcessoJuridico("000208-0252012.5.15.0049")).toBe(false); }); + + test("when the órgão (J) is not one of the nine segments of art. 1º, § 4º", () => { + expect(isValidProcessoJuridico("0000100-69.2008.0.00.0000")).toBe(false); + }); + + test("when the Justiça Federal carries a region beyond the sixth (art. 1º, § 5º, III)", () => { + expect(isValidProcessoJuridico("0000100-41.2008.4.07.0000")).toBe(false); + }); + + test("when the Justiça Estadual carries a tribunal beyond the twenty seventh (art. 1º, § 5º, VII)", () => { + expect(isValidProcessoJuridico("0000100-23.2008.8.28.0000")).toBe(false); + }); + + test("when the Justiça Estadual zeroes the tribunal, which has no superior court of its own", () => { + expect(isValidProcessoJuridico("0000100-03.2008.8.00.0000")).toBe(false); + }); + + test("when the Justiça Militar Estadual names a state without a military court (art. 1º, § 5º, VIII)", () => { + expect(isValidProcessoJuridico("0000100-89.2008.9.01.0000")).toBe(false); + }); + + test("when a superior court carries a tribunal instead of the zeroed field (art. 1º, § 5º, I)", () => { + expect(isValidProcessoJuridico("0000100-58.2008.1.01.0000")).toBe(false); + }); + + test("when a segment without a council carries the council code (art. 1º, § 5º, II)", () => { + expect(isValidProcessoJuridico("0000100-14.2008.9.90.0000")).toBe(false); + }); }); describe("should return true", () => { @@ -66,6 +97,59 @@ describe("isValidProcessoJuridico", () => { test("when is a processo juridico valid with the legacy fused mask", () => { expect(isValidProcessoJuridico("0002080-25.2012.515.0049")).toBe(true); }); + + test("when the órgão is the Supremo Tribunal Federal (art. 1º, § 4º, I)", () => { + expect(isValidProcessoJuridico("0000100-85.2008.1.00.0000")).toBe(true); + }); + + test("when the órgão is the Conselho Nacional de Justiça (art. 1º, § 4º, II)", () => { + expect(isValidProcessoJuridico("0000100-04.2008.2.00.0000")).toBe(true); + }); + + test("when the órgão is the Superior Tribunal de Justiça (art. 1º, § 4º, III)", () => { + expect(isValidProcessoJuridico("0000100-20.2008.3.00.0000")).toBe(true); + }); + + test("when the órgão is the Justiça Federal and the tribunal a TRF (art. 1º, § 5º, III)", () => { + expect(isValidProcessoJuridico("0000100-09.2008.4.01.0000")).toBe(true); + }); + + test("when the órgão is the Justiça do Trabalho and the tribunal a TRT (art. 1º, § 5º, IV)", () => { + expect(isValidProcessoJuridico("0000100-35.2008.5.15.0000")).toBe(true); + }); + + test("when the órgão is the Justiça Eleitoral and the tribunal a TRE (art. 1º, § 5º, V)", () => { + expect(isValidProcessoJuridico("0000100-18.2008.6.27.0000")).toBe(true); + }); + + test("when the órgão is the Justiça Militar da União and the tribunal a CJM (art. 1º, § 5º, VI)", () => { + expect(isValidProcessoJuridico("0000100-51.2008.7.12.0000")).toBe(true); + }); + + test("when the órgão is the Justiça Estadual and the tribunal a TJ (art. 1º, § 5º, VII)", () => { + expect(isValidProcessoJuridico("0000100-73.2008.8.01.0000")).toBe(true); + }); + + test("when the órgão is the Justiça Militar Estadual and the tribunal a TJM (art. 1º, § 5º, VIII)", () => { + expect(isValidProcessoJuridico("0000100-56.2008.9.13.0000")).toBe(true); + expect(isValidProcessoJuridico("0000100-34.2008.9.21.0000")).toBe(true); + expect(isValidProcessoJuridico("0000100-93.2008.9.26.0000")).toBe(true); + }); + + test("when the Justiça Federal names the TRF da 6ª Região, added by Resolução CNJ nº 477/2022", () => { + expect(isValidProcessoJuridico("0000100-68.2008.4.06.0000")).toBe(true); + }); + + test("when the number originates in the CJF or in the CSJT, whose tribunal is 90 (art. 1º, § 5º, II)", () => { + expect(isValidProcessoJuridico("0000100-31.2008.4.90.0000")).toBe(true); + expect(isValidProcessoJuridico("0000100-47.2008.5.90.0000")).toBe(true); + }); + + test("when the TST, the TSE or the STM zeroes the tribunal (art. 1º, § 5º, I)", () => { + expect(isValidProcessoJuridico("0000100-52.2008.5.00.0000")).toBe(true); + expect(isValidProcessoJuridico("0000100-68.2008.6.00.0000")).toBe(true); + expect(isValidProcessoJuridico("0000100-84.2008.7.00.0000")).toBe(true); + }); }); describe("properties", () => { @@ -87,6 +171,25 @@ describe("isValidProcessoJuridico", () => { expectRejected(isValidProcessoJuridico, digitsOfOtherLength(30, [PROCESSO_JURIDICO_LENGTH])); }); + test("should reject every tribunal the órgão of the value does not have", () => { + const courts = [...PROCESSO_JURIDICO_TRIBUNALS.keys()]; + + fc.assert( + fc.property( + fc.constantFrom(...courts), + fc.integer({ min: 0, max: 99 }), + (court, tribunal) => { + fc.pre(!(PROCESSO_JURIDICO_TRIBUNALS.get(court) as number[]).includes(tribunal)); + + const base = `00001002008${court}${String(tribunal).padStart(2, "0")}0000`; + const checkDigits = (98n - ((BigInt(base) * 100n) % 97n)).toString().padStart(2, "0"); + + expect(isValidProcessoJuridico(`0000100${checkDigits}${base.slice(7)}`)).toBe(false); + }, + ), + ); + }); + test("should never throw and always return a boolean", () => { expectAlwaysReturnsType(isValidProcessoJuridico, "boolean", anyValue); }); diff --git a/src/is-valid-processo-juridico/is-valid-processo-juridico.ts b/src/is-valid-processo-juridico/is-valid-processo-juridico.ts index c4d87a98..fb470dfd 100644 --- a/src/is-valid-processo-juridico/is-valid-processo-juridico.ts +++ b/src/is-valid-processo-juridico/is-valid-processo-juridico.ts @@ -1,8 +1,12 @@ +import { PROCESSO_JURIDICO_TRIBUNALS } from "../_internals/constants/processo-juridico"; import { CHECK_DIGIT_LENGTH, CHECK_DIGIT_START_POSITION, + COURT_POSITION, MOD_97_10_QUOTIENT, MOD_97_10_SUM, + TRIBUNAL_LENGTH, + TRIBUNAL_START_POSITION, } from "./constants"; const SEPARATORS_REGEX = /[\s.-]/g; @@ -38,9 +42,26 @@ const verifyCheckDigit = (value: string): boolean => { return verifier === verificationDigits; }; +const verifyCourtAndTribunal = (value: string): boolean => { + const tribunals = PROCESSO_JURIDICO_TRIBUNALS.get(Number(value.charAt(COURT_POSITION))); + + if (tribunals === undefined) return false; + + return tribunals.includes( + Number(value.slice(TRIBUNAL_START_POSITION, TRIBUNAL_START_POSITION + TRIBUNAL_LENGTH)), + ); +}; + /** * Validates a Brazilian Processo Jurídico (court case) number. * + * Three things are checked: the `NNNNNNN-DD.AAAA.J.TR.OOOO` layout, the `DD` check digits (ISO + * 7064 MOD 97-10) and the `J` and `TR` pair, which has to name an órgão and a tribunal Resolução + * CNJ nº 65/2008 actually created, so a number carrying a correct check digit but a court that + * does not exist, `0000100-23.2008.8.28.0000`, is rejected. The unidade de origem (`OOOO`) is + * only read as four digits: art. 1º, § 6º leaves its codification to each tribunal, so there is + * no central list to check it against. + * * The CNJ mask separators (whitespace, `.` and `-`) are accepted between the * `NNNNNNN-DD.AAAA.J.TR.OOOO` fields, and whitespace around the value is ignored, but any other * character, a letter in particular, makes the value invalid. @@ -53,10 +74,12 @@ const verifyCheckDigit = (value: string): boolean => { * isValidProcessoJuridico("00020802520125150049"); // true * isValidProcessoJuridico("0002080-25.2012.5.15.0049"); // true * isValidProcessoJuridico(" 0002080-25.2012.5.15.0049 "); // true (surrounding whitespace) + * isValidProcessoJuridico("0000100-23.2008.8.28.0000"); // false (there is no 28th Tribunal de Justiça) * isValidProcessoJuridico("ab00020802520125150049"); // false (invalid format) * ``` * - * Resolução CNJ nº 65/2008 defines this Número Único de Processo layout and its check digits. + * Resolução CNJ nº 65/2008 defines this Número Único de Processo layout and its check digits, and + * closes the list of órgão (`J`) and tribunal (`TR`) codes in art. 1º, § 4º and § 5º. * * @see Official: https://atos.cnj.jus.br/atos/detalhar/119 */ @@ -65,5 +88,9 @@ export const isValidProcessoJuridico = (value: string): boolean => { if (!FORMAT_REGEX.test(value.trim())) return false; - return verifyCheckDigit(value.replace(SEPARATORS_REGEX, "")); + const digits = value.replace(SEPARATORS_REGEX, ""); + + if (!verifyCheckDigit(digits)) return false; + + return verifyCourtAndTribunal(digits); }; diff --git a/src/is-valid-registro-profissional/constants.ts b/src/is-valid-registro-profissional/constants.ts index 95b759b0..7af5c522 100644 --- a/src/is-valid-registro-profissional/constants.ts +++ b/src/is-valid-registro-profissional/constants.ts @@ -17,7 +17,12 @@ export const CRO_REGEX = /^(?\d{3,6})(?[A-Z]{2})$/; export const CRP_REGEX = /^(?\d{2})(?\d{4,6})$/; -export const CRC_REGEX = /^(?[A-Z]{2})(?\d{6})(?[OPT])(?\d)$/; +/** + * UF, six digits, the tipo de registro (`O` Originário or `P` Provisório), the check digit and, + * for a Registro Transferido or Secundário, the `T`/`S` suffix plus the UF of the destination CRC. + */ +export const CRC_REGEX = + /^(?[A-Z]{2})(?\d{6})(?[OP])(?\d)(?:(?[TS])(?[A-Z]{2}))?$/; /** Lowest regional code of the CFP system, CRP-01. */ export const CRP_MIN_REGION = 1; diff --git a/src/is-valid-registro-profissional/is-valid-registro-profissional.test.ts b/src/is-valid-registro-profissional/is-valid-registro-profissional.test.ts index 42226716..6eb8bfc5 100644 --- a/src/is-valid-registro-profissional/is-valid-registro-profissional.test.ts +++ b/src/is-valid-registro-profissional/is-valid-registro-profissional.test.ts @@ -14,121 +14,192 @@ describe("isValidRegistroProfissional", () => { describe("should return false", () => { test("when value is null", () => { // @ts-expect-error: intentionally invalid input - expect(isValidRegistroProfissional(null, { council: "OAB" })).toBe(false); + expect(isValidRegistroProfissional({ value: null, council: "OAB" })).toBe(false); }); test("when value is an empty string", () => { - expect(isValidRegistroProfissional("", { council: "OAB" })).toBe(false); + expect(isValidRegistroProfissional({ value: "", council: "OAB" })).toBe(false); }); - test("when options is null", () => { + test("when the single argument is not an object", () => { // @ts-expect-error: intentionally invalid input - expect(isValidRegistroProfissional("123456/SP", null)).toBe(false); + expect(isValidRegistroProfissional(null)).toBe(false); + // @ts-expect-error: intentionally invalid input + expect(isValidRegistroProfissional()).toBe(false); + // @ts-expect-error: intentionally invalid input + expect(isValidRegistroProfissional("123456/SP")).toBe(false); + // @ts-expect-error: intentionally invalid input + expect(isValidRegistroProfissional(123_456)).toBe(false); + }); + + test("when the value is a number, even when its digits would match as a string", () => { + // @ts-expect-error: intentionally invalid input + expect(isValidRegistroProfissional({ value: 2_412_345, council: "CRP" })).toBe(false); + }); + + test("when the object carries no value", () => { + // @ts-expect-error: intentionally invalid input + expect(isValidRegistroProfissional({ council: "OAB" })).toBe(false); + }); + + test("when the object carries no council", () => { + // @ts-expect-error: intentionally invalid input + expect(isValidRegistroProfissional({ value: "123456/SP" })).toBe(false); }); test("when the council is not supported (e.g. CREA)", () => { // @ts-expect-error: intentionally invalid input - expect(isValidRegistroProfissional("1234567890", { council: "CREA" })).toBe(false); + expect(isValidRegistroProfissional({ value: "1234567890", council: "CREA" })).toBe(false); }); test("when an OAB number has no UF", () => { - expect(isValidRegistroProfissional("123456", { council: "OAB" })).toBe(false); + expect(isValidRegistroProfissional({ value: "123456", council: "OAB" })).toBe(false); }); test("when an OAB number has too many digits", () => { - expect(isValidRegistroProfissional("1234567/SP", { council: "OAB" })).toBe(false); + expect(isValidRegistroProfissional({ value: "1234567/SP", council: "OAB" })).toBe(false); }); test("when the UF is not a real Brazilian state code", () => { - expect(isValidRegistroProfissional("123456/ZZ", { council: "OAB" })).toBe(false); + expect(isValidRegistroProfissional({ value: "123456/ZZ", council: "OAB" })).toBe(false); }); - test("when the UF does not match options.stateCode", () => { - expect(isValidRegistroProfissional("123456-RJ", { council: "OAB", stateCode: "SP" })).toBe( - false, - ); + test("when the UF does not match params.stateCode", () => { + expect( + isValidRegistroProfissional({ value: "123456-RJ", council: "OAB", stateCode: "SP" }), + ).toBe(false); }); test("when a CRP number has letters instead of the regional code", () => { - expect(isValidRegistroProfissional("SP/12345", { council: "CRP" })).toBe(false); + expect(isValidRegistroProfissional({ value: "SP/12345", council: "CRP" })).toBe(false); }); test("when a CRC number is missing the category letter", () => { - expect(isValidRegistroProfissional("SP-123456-3", { council: "CRC" })).toBe(false); + expect(isValidRegistroProfissional({ value: "SP-123456-3", council: "CRC" })).toBe(false); }); test("when a CRC number is missing the check digit", () => { - expect(isValidRegistroProfissional("SP-123456/O", { council: "CRC" })).toBe(false); + expect(isValidRegistroProfissional({ value: "SP-123456/O", council: "CRC" })).toBe(false); }); test("when a CRC number of ordem has 5 digits instead of the 6 of the Manual de Registro", () => { - expect(isValidRegistroProfissional("SP-12345/O-3", { council: "CRC" })).toBe(false); + expect(isValidRegistroProfissional({ value: "SP-12345/O-3", council: "CRC" })).toBe(false); }); test("when a CRC number carries a letter that is not a tipo de registro", () => { - expect(isValidRegistroProfissional("SP-123456/X-3", { council: "CRC" })).toBe(false); + expect(isValidRegistroProfissional({ value: "SP-123456/X-3", council: "CRC" })).toBe(false); + }); + + test('when a CRC number puts "T" in the tipo de registro slot, which the Manual de Registro restricts to O and P', () => { + expect(isValidRegistroProfissional({ value: "SP-123456/T-3", council: "CRC" })).toBe(false); + }); + + test('when a CRC number puts "S" in the tipo de registro slot', () => { + expect(isValidRegistroProfissional({ value: "SP-123456/S-3", council: "CRC" })).toBe(false); + }); + + test("when the destination UF of a transferred CRC number is not a real Brazilian state code", () => { + expect(isValidRegistroProfissional({ value: "SP-123456/O-3 T-ZZ", council: "CRC" })).toBe( + false, + ); + }); + + test("when a transferred CRC number carries no destination UF at all", () => { + expect(isValidRegistroProfissional({ value: "SP-123456/O-3 T", council: "CRC" })).toBe(false); }); test("when a CRP regional code is 00, below the CRP-01 of the CFP system", () => { - expect(isValidRegistroProfissional("00/12345", { council: "CRP" })).toBe(false); + expect(isValidRegistroProfissional({ value: "00/12345", council: "CRP" })).toBe(false); }); test("when a CRP regional code is 25, above the CRP-24 of the CFP system", () => { - expect(isValidRegistroProfissional("25/12345", { council: "CRP" })).toBe(false); + expect(isValidRegistroProfissional({ value: "25/12345", council: "CRP" })).toBe(false); }); test("when a CRP regional code is 99, which no Conselho Regional carries", () => { - expect(isValidRegistroProfissional("99/12345", { council: "CRP" })).toBe(false); + expect(isValidRegistroProfissional({ value: "99/12345", council: "CRP" })).toBe(false); }); }); describe("should return true", () => { test("for a valid OAB number", () => { - expect(isValidRegistroProfissional("123456/SP", { council: "OAB" })).toBe(true); + expect(isValidRegistroProfissional({ value: "123456/SP", council: "OAB" })).toBe(true); }); - test("for a valid OAB number matching options.stateCode", () => { - expect(isValidRegistroProfissional("123456-SP", { council: "OAB", stateCode: "SP" })).toBe( - true, - ); + test("for a valid OAB number matching params.stateCode", () => { + expect( + isValidRegistroProfissional({ value: "123456-SP", council: "OAB", stateCode: "SP" }), + ).toBe(true); }); test("for a valid CRM number", () => { - expect(isValidRegistroProfissional("54321/RJ", { council: "CRM" })).toBe(true); + expect(isValidRegistroProfissional({ value: "54321/RJ", council: "CRM" })).toBe(true); }); test("for a valid CRO number", () => { - expect(isValidRegistroProfissional("12345/MG", { council: "CRO" })).toBe(true); + expect(isValidRegistroProfissional({ value: "12345/MG", council: "CRO" })).toBe(true); }); - test("for a valid CRP number, ignoring options.stateCode", () => { - expect(isValidRegistroProfissional("06/12345", { council: "CRP", stateCode: "SP" })).toBe( - true, - ); + test("for a valid CRP number, ignoring params.stateCode", () => { + expect( + isValidRegistroProfissional({ value: "06/12345", council: "CRP", stateCode: "SP" }), + ).toBe(true); }); test("for the first regional code of the CFP system, CRP-01", () => { - expect(isValidRegistroProfissional("01/12345", { council: "CRP" })).toBe(true); + expect(isValidRegistroProfissional({ value: "01/12345", council: "CRP" })).toBe(true); }); test("for the last regional code of the CFP system, CRP-24", () => { - expect(isValidRegistroProfissional("24/12345", { council: "CRP" })).toBe(true); + expect(isValidRegistroProfissional({ value: "24/12345", council: "CRP" })).toBe(true); + expect(isValidRegistroProfissional({ value: "2412345", council: "CRP" })).toBe(true); }); test("for a valid CRC number of a registro originário", () => { - expect(isValidRegistroProfissional("SP-123456/O-3", { council: "CRC" })).toBe(true); + expect(isValidRegistroProfissional({ value: "SP-123456/O-3", council: "CRC" })).toBe(true); }); test("for DF-000001/P-7, the Manual de Registro's own example of a registro provisório", () => { - expect(isValidRegistroProfissional("DF-000001/P-7", { council: "CRC" })).toBe(true); + expect(isValidRegistroProfissional({ value: "DF-000001/P-7", council: "CRC" })).toBe(true); }); test("for DF-000002/O-5, the Manual de Registro's own example of a registro originário", () => { - expect(isValidRegistroProfissional("DF-000002/O-5", { council: "CRC" })).toBe(true); + expect(isValidRegistroProfissional({ value: "DF-000002/O-5", council: "CRC" })).toBe(true); + }); + + test('for "SP-123456/O-3 T-MG", the Manual de Registro\'s own example of a registro definitivo transferido', () => { + expect(isValidRegistroProfissional({ value: "SP-123456/O-3 T-MG", council: "CRC" })).toBe( + true, + ); + }); + + test('for "TO-654321/P-8 T-SC", the Manual de Registro\'s own example of a registro provisório transferido', () => { + expect(isValidRegistroProfissional({ value: "TO-654321/P-8 T-SC", council: "CRC" })).toBe( + true, + ); + }); + + test('for "PI-111222/O-5 S-AC", the Manual de Registro\'s own example of a registro secundário', () => { + expect(isValidRegistroProfissional({ value: "PI-111222/O-5 S-AC", council: "CRC" })).toBe( + true, + ); }); - test("for a valid CRC number of a registro transferido", () => { - expect(isValidRegistroProfissional("RJ-654321/T-9", { council: "CRC" })).toBe(true); + test("for a transferred CRC number matching params.stateCode, which is the originating UF", () => { + expect( + isValidRegistroProfissional({ + value: "SP-123456/O-3 T-MG", + council: "CRC", + stateCode: "SP", + }), + ).toBe(true); + expect( + isValidRegistroProfissional({ + value: "SP-123456/O-3 T-MG", + council: "CRC", + stateCode: "MG", + }), + ).toBe(false); }); }); @@ -141,15 +212,18 @@ describe("isValidRegistroProfissional", () => { fc.assert( fc.property(states, numbers, (stateCode, number) => { for (const council of ["OAB", "CRM", "CRO"] as const) { - expect(isValidRegistroProfissional(`${number}/${stateCode}`, { council })).toBe(true); + expect(isValidRegistroProfissional({ value: `${number}/${stateCode}`, council })).toBe( + true, + ); expect( - isValidRegistroProfissional(`${number}-${stateCode}`, { council, stateCode }), + isValidRegistroProfissional({ value: `${number}-${stateCode}`, council, stateCode }), ).toBe(true); } - expect(isValidRegistroProfissional(`06/${number}`, { council: "CRP" })).toBe(true); + expect(isValidRegistroProfissional({ value: `06/${number}`, council: "CRP" })).toBe(true); expect( - isValidRegistroProfissional(`${stateCode}-${String(number).padStart(6, "0")}/O-3`, { + isValidRegistroProfissional({ + value: `${stateCode}-${String(number).padStart(6, "0")}/O-3`, council: "CRC", }), ).toBe(true); @@ -163,7 +237,7 @@ describe("isValidRegistroProfissional", () => { const value = `${String(region).padStart(2, "0")}/${number}`; const expected = region >= 1 && region <= 24; - expect(isValidRegistroProfissional(value, { council: "CRP" })).toBe(expected); + expect(isValidRegistroProfissional({ value, council: "CRP" })).toBe(expected); }), ); }); @@ -173,12 +247,12 @@ describe("isValidRegistroProfissional", () => { fc.property( states, fc.integer({ min: 1, max: 9_999_999 }), - fc.constantFrom("O", "P", "T"), + fc.constantFrom("O", "P"), (stateCode, number, category) => { const digits = String(number); const value = `${stateCode}-${digits}/${category}-3`; - expect(isValidRegistroProfissional(value, { council: "CRC" })).toBe( + expect(isValidRegistroProfissional({ value, council: "CRC" })).toBe( digits.length === 6, ); }, @@ -186,6 +260,34 @@ describe("isValidRegistroProfissional", () => { ); }); + test('should accept the "T" and "S" suffixes only after the check digit and only with a real destination UF', () => { + fc.assert( + fc.property( + states, + states, + fc.constantFrom("O", "P"), + (stateCode, destination, category) => { + const number = `${stateCode}-123456/${category}-3`; + + for (const suffix of ["T", "S"]) { + expect( + isValidRegistroProfissional({ + value: `${number} ${suffix}-${destination}`, + council: "CRC", + }), + ).toBe(true); + expect( + isValidRegistroProfissional({ + value: `${stateCode}-123456/${suffix}-3`, + council: "CRC", + }), + ).toBe(false); + } + }, + ), + ); + }); + test("should reject a registration whose UF is not the expected one", () => { fc.assert( fc.property(states, states, numbers, (stateCode, other, number) => { @@ -193,7 +295,7 @@ describe("isValidRegistroProfissional", () => { const value = `${number}/${other}`; - expect(isValidRegistroProfissional(value, { council: "OAB", stateCode })).toBe(false); + expect(isValidRegistroProfissional({ value, council: "OAB", stateCode })).toBe(false); }), ); }); @@ -201,15 +303,15 @@ describe("isValidRegistroProfissional", () => { test("should reject a number that carries no UF at all", () => { fc.assert( fc.property(numbers, (number) => { - expect(isValidRegistroProfissional(`${number}`, { council: "CRM" })).toBe(false); + expect(isValidRegistroProfissional({ value: `${number}`, council: "CRM" })).toBe(false); }), ); }); test("should never throw and always judge a registration with a boolean", () => { fc.assert( - fc.property(fc.anything(), fc.anything(), (value, options) => { - const result = isValidRegistroProfissional(value as string, options as never); + fc.property(fc.anything(), (params) => { + const result = isValidRegistroProfissional(params as IsValidRegistroProfissionalOptions); expect(typeof result).toBe("boolean"); }), @@ -219,11 +321,14 @@ describe("isValidRegistroProfissional", () => { }); describe("isValidRegistroProfissional types", () => { - test("should take a string, required options, and return a boolean", () => { - expectTypeOf(isValidRegistroProfissional).parameter(0).toEqualTypeOf(); + test("should take a single required object and return a boolean", () => { expectTypeOf(isValidRegistroProfissional) - .parameter(1) + .parameter(0) .toEqualTypeOf(); + expectTypeOf(isValidRegistroProfissional).parameters.toEqualTypeOf< + [IsValidRegistroProfissionalOptions] + >(); + expectTypeOf().toEqualTypeOf(); expectTypeOf< IsValidRegistroProfissionalOptions["council"] >().toEqualTypeOf(); diff --git a/src/is-valid-registro-profissional/is-valid-registro-profissional.ts b/src/is-valid-registro-profissional/is-valid-registro-profissional.ts index d6fbd193..8f3a8217 100644 --- a/src/is-valid-registro-profissional/is-valid-registro-profissional.ts +++ b/src/is-valid-registro-profissional/is-valid-registro-profissional.ts @@ -1,4 +1,5 @@ import { DATA, type StateCode } from "../_internals/constants/states"; +import { isNullish } from "../_internals/is-nullish/is-nullish"; import { sanitizeToAlphanumeric } from "../_internals/sanitize-to-alphanumeric/sanitize-to-alphanumeric"; import { CRC_REGEX, @@ -10,8 +11,12 @@ import { type RegistroProfissionalCouncil, } from "./constants"; -/** The options `isValidRegistroProfissional` takes: the professional council and, optionally, the UF the registration must belong to. */ +export type { StateCode } from "../_internals/constants/states"; + +/** The registration `isValidRegistroProfissional` checks: the number, the council that issued it and, optionally, the UF it must belong to. */ export type IsValidRegistroProfissionalOptions = { + /** The registration number to be validated, e.g. `"123456/SP"`. */ + value: string; /** The professional council that issued the registration number. */ council: RegistroProfissionalCouncil; /** The UF the registration is expected to belong to. Ignored for `"CRP"` (see below). */ @@ -40,7 +45,7 @@ const isKnownCrpRegion = (value: string): boolean => { * * This is a structural check only: it validates the digit count and, for the councils whose * number embeds the UF, that the UF is a real Brazilian state code, optionally matching - * `options.stateCode`. It never computes or asserts a check digit, even for CRC, whose format + * `params.stateCode`. It never computes or asserts a check digit, even for CRC, whose format * includes one (the digit is only checked for presence and shape). * * Supported councils and what is validated: @@ -50,76 +55,106 @@ const isKnownCrpRegion = (value: string): boolean => { * - `"CRP"` (Conselho Regional de Psicologia): 2 digit regional code + 4 to 6 digits, e.g. * `"06/12345"`. The regional code must be one of the 24 Conselhos Regionais of the CFP * system, CRP-01 to CRP-24. It is not a literal UF (some regions cover more than one state), - * so `options.stateCode` is ignored for this council. + * so `params.stateCode` is ignored for this council. * - `"CRC"` (Conselho Regional de Contabilidade): UF + 6 digits + the tipo de registro (`"O"` - * Originário, `"P"` Provisório or `"T"` Transferido) + 1 check digit whose value is not - * verified, e.g. `"SP-123456/O-3"`. The letter says nothing about the professional category: - * the Manual de Registro states that the distinction between `"O"` and `"P"` applies - * "independentemente da categoria profissional do contabilista", and `"T"` comes from the - * Resolução CFC nº 1.707/2023, art. 5º, parágrafo único, which appends it to the número do - * Registro Originário when a registration is transferred to another CRC. + * Originário or `"P"` Provisório) + 1 check digit whose value is not verified, e.g. + * `"SP-123456/O-3"`. The letter says nothing about the professional category: the Manual de + * Registro states that the distinction between `"O"` and `"P"` applies "independentemente da + * categoria profissional do contabilista". A Registro Transferido or Secundário is written by + * appending `"T"` or `"S"` and the UF of the destination CRC **after** the check digit, as the + * Resolução CFC nº 1.707/2023, art. 5º, parágrafo único, and the Manual's own examples + * (`"SP-123456/O-3 T-MG"`, `"TO-654321/P-8 T-SC"`, `"PI-111222/O-5 S-AC"`) put it. Both UFs + * have to be real state codes; `params.stateCode` is compared against the originating one, + * the UF the número do Registro Originário belongs to. * * CREA (Conselho Regional de Engenharia e Agronomia) is not supported: since the 2016 national * unification (RNP) its registration number format could not be confirmed from an official, * publicly documented source. * - * Only the CRC and the CRP shapes rest on a published source. The OAB, the CFM and the CFO do - * not publish the format of the numbers their seccionais and regionais issue, so the digit - * ranges accepted for `"OAB"`, `"CRM"` and `"CRO"` are conventional rather than normative. + * Only the CRC shape and the CRP regional codes rest on a published source: the CFP page lists + * the 24 Conselhos Regionais and nothing else, so the 4 to 6 digit body of a CRP number is as + * unsourced as the OAB, CRM and CRO ranges. The OAB, the CFM and the CFO do not publish the + * format of the numbers their seccionais and regionais issue, so the digit ranges accepted for + * `"OAB"`, `"CRM"` and `"CRO"` are conventional rather than normative, and two counterexamples + * are known: the OAB/SP public search field is `maxlength="7"` and rejects only inputs of two + * characters or fewer, and the CFM's Manual de Procedimentos Administrativos documents a `300` + * prefixed CRM for foreign-trained physicians and a trailing `P` for inscrição provisória, + * neither of which the accepted shape can express. + * + * Everything it needs travels in a single object, the shape `isValidBankAccount` takes: a + * registration number means nothing without the council that issued it, so the two are read + * together. A value that is not an object, or one missing `value` or `council`, is `false` like + * any other registration it cannot recognise. * - * @param {string} value - The registration number to be validated. - * @param {IsValidRegistroProfissionalOptions} options - The validation options. - * @param {RegistroProfissionalCouncil} options.council - The issuing council. - * @param {string} [options.stateCode] - The expected UF, ignored for `"CRP"`. + * @param {IsValidRegistroProfissionalOptions} params - The registration to be validated. + * @param {string} params.value - The registration number, e.g. `"123456/SP"`. + * @param {RegistroProfissionalCouncil} params.council - The issuing council. + * @param {string} [params.stateCode] - The expected UF, ignored for `"CRP"`. * @returns {boolean} True if the value has the structure of a registration number for the * given council, false otherwise. * * @example * ```typescript - * isValidRegistroProfissional("123456/SP", { council: "OAB" }); // true - * isValidRegistroProfissional("123456-SP", { council: "OAB", stateCode: "SP" }); // true - * isValidRegistroProfissional("123456-RJ", { council: "OAB", stateCode: "SP" }); // false (UF mismatch) - * isValidRegistroProfissional("06/12345", { council: "CRP" }); // true - * isValidRegistroProfissional("SP-123456/O-3", { council: "CRC" }); // true - * isValidRegistroProfissional("123456", { council: "OAB" }); // false (no UF) + * isValidRegistroProfissional({ value: "123456/SP", council: "OAB" }); // true + * isValidRegistroProfissional({ value: "123456-SP", council: "OAB", stateCode: "SP" }); // true + * isValidRegistroProfissional({ value: "123456-RJ", council: "OAB", stateCode: "SP" }); // false (UF mismatch) + * isValidRegistroProfissional({ value: "06/12345", council: "CRP" }); // true + * isValidRegistroProfissional({ value: "SP-123456/O-3", council: "CRC" }); // true + * isValidRegistroProfissional({ value: "SP-123456/O-3 T-MG", council: "CRC" }); // true (transferido) + * isValidRegistroProfissional({ value: "SP-123456/T-3", council: "CRC" }); // false ("T" is not a tipo) + * isValidRegistroProfissional({ value: "123456", council: "OAB" }); // false (no UF) * ``` * - * @see Official: https://cfc.org.br/wp-content/uploads/2018/04/1_manual_registro.pdf Manual de - * Registro do Sistema CFC/CRCs, item 1.1: the CRC registration is the sigla of the UF, six - * sequential digits, the letter of the tipo de registro and a check digit, with "UF-000001/P-7" - * and "UF-000002/O-5" as its own worked examples. - * @see Official: https://site.cfp.org.br/cfp/sistema-conselhos/conselhos-pelo-brasil/ Conselho - * Federal de Psicologia: the 24 Conselhos Regionais of the system, numbered CRP-01 to CRP-24. - * @see Based on: https://www.oab.org.br/ Ordem dos Advogados do Brasil (OAB), which publishes - * no format for the número de inscrição and the seccional. - * @see Based on: https://portal.cfm.org.br/ Conselho Federal de Medicina (CRM), which publishes - * no format for the registration number and the UF. - * @see Based on: https://cfo.org.br/ Conselho Federal de Odontologia (CRO), which publishes no - * format for the registration number and the UF. + * @see Official: https://cfc.org.br/wp-content/uploads/2018/04/1_manual_registro.pdf + * Manual de Registro do Sistema CFC/CRCs, item 1.1: the CRC registration is the sigla of the UF, + * six sequential digits, the letter of the tipo de registro and a check digit, with + * "UF-000001/P-7" and "UF-000002/O-5" as its own worked examples; the same item adds the "T" of + * the Registro Transferido "ao número do Registro Definitivo Originário ou Registro Provisório … + * acompanhada de um hífen e da sigla designativa da jurisdição do CRC de destino". + * @see Official: https://www1.cfc.org.br/sisweb/SRE/docs/Res_1707.pdf + * Resolução CFC nº 1.707/2023, art. 5º parágrafo único: "No caso de Registro Transferido, ao + * número do Registro Originário será acrescentada a letra 'T', acompanhada da sigla designativa da + * jurisdição do CRC de destino." + * @see Official: https://site.cfp.org.br/cfp/sistema-conselhos/conselhos-pelo-brasil/ + * Conselho Federal de Psicologia: the 24 Conselhos Regionais of the system, numbered CRP-01 to + * CRP-24. The page establishes the regional codes only; it publishes no length for the inscription + * number itself. + * @see Official: https://www.oab.org.br/ + * Ordem dos Advogados do Brasil (OAB), the federal body that regulates the profession, which + * publishes no format for the número de inscrição and the seccional. + * @see Official: https://portal.cfm.org.br/ + * Conselho Federal de Medicina (CFM), the autarquia federal that regulates the profession, which + * publishes no format for the registration number and the UF. + * @see Official: https://cfo.org.br/ + * Conselho Federal de Odontologia (CFO), the autarquia federal that regulates the profession, + * which publishes no format for the registration number and the UF. */ export const isValidRegistroProfissional = ( - value: string, - options: IsValidRegistroProfissionalOptions, + params: IsValidRegistroProfissionalOptions, ): boolean => { - if (typeof value !== "string") return false; + if (isNullish(params)) return false; - if (typeof options !== "object" || options === null) return false; + const { value, council, stateCode } = params; - if (!Object.hasOwn(REGEX_BY_COUNCIL, options.council)) return false; + if (typeof value !== "string") return false; + + if (!Object.hasOwn(REGEX_BY_COUNCIL, council)) return false; - const regex = REGEX_BY_COUNCIL[options.council]; + const regex = REGEX_BY_COUNCIL[council]; const match = regex.exec(sanitizeToAlphanumeric(value)); if (!match?.groups) return false; - const { region, uf } = match.groups; + const { region, uf, transferUf } = match.groups; if (region !== undefined && !isKnownCrpRegion(region)) return false; + if (transferUf !== undefined && !isKnownStateCode(transferUf)) return false; + if (uf === undefined) return true; if (!isKnownStateCode(uf)) return false; - return !options.stateCode || uf === options.stateCode; + return !stateCode || uf === stateCode; }; diff --git a/src/is-valid-renavam/is-valid-renavam.ts b/src/is-valid-renavam/is-valid-renavam.ts index 277b55ba..28633133 100644 --- a/src/is-valid-renavam/is-valid-renavam.ts +++ b/src/is-valid-renavam/is-valid-renavam.ts @@ -1,7 +1,10 @@ +import { calculateRenavamCheckDigit } from "../_internals/calculate-renavam-check-digit/calculate-renavam-check-digit"; import { isRepeatedDigits } from "../_internals/is-repeated-digits/is-repeated-digits"; const RENAVAM_LENGTH = 11; +const BASE_LENGTH = 10; + const SEPARATORS_REGEX = /[\s.-]/g; const FORMAT_REGEX = /^\d{9}$|^\d{11}$/; @@ -53,28 +56,9 @@ export const isValidRenavam = (renavam: string | number): boolean => { if (isRepeatedDigits(paddedDigits)) return false; - const renavamWithoutDigit = paddedDigits.slice(0, 10); - - let reversedRenavam = ""; - - for (const char of renavamWithoutDigit) { - reversedRenavam = char + reversedRenavam; - } - - let sum = 0; - let multiplier = 2; - for (const char of reversedRenavam) { - const digit = Number.parseInt(char, 10); - sum += digit * multiplier; - - multiplier = multiplier >= 9 ? 2 : multiplier + 1; - } - - const mod11 = sum % 11; - - const expectedDigit = mod11 <= 1 ? 0 : 11 - mod11; + const expectedDigit = calculateRenavamCheckDigit(paddedDigits.slice(0, BASE_LENGTH)); - const actualDigit = Number.parseInt(paddedDigits.charAt(10), 10); + const actualDigit = Number.parseInt(paddedDigits.charAt(BASE_LENGTH), 10); return expectedDigit === actualDigit; }; diff --git a/src/is-valid-service-phone/is-valid-service-phone.test.ts b/src/is-valid-service-phone/is-valid-service-phone.test.ts index 70610ebf..f5c2b7ef 100644 --- a/src/is-valid-service-phone/is-valid-service-phone.test.ts +++ b/src/is-valid-service-phone/is-valid-service-phone.test.ts @@ -58,6 +58,11 @@ describe("isValidServicePhone", () => { expect(isValidServicePhone("200")).toBe(false); expect(isValidServicePhone("999")).toBe(false); }); + + test("for the handset emergency aliases 112 and 911, which Anatel designates in neither the Anexo of Ato nº 43.151/2004 nor Ato nº 12.712/2024 (and 911 falls outside the 1N₂N₁ range Resolução nº 749/2022 art. 13 destines to public utility services)", () => { + expect(isValidServicePhone("112")).toBe(false); + expect(isValidServicePhone("911")).toBe(false); + }); }); describe("should return true", () => { @@ -93,8 +98,8 @@ describe("isValidServicePhone", () => { test("for the public utility codes", () => { expect(isValidServicePhone("100")).toBe(true); expect(isValidServicePhone("102")).toBe(true); - expect(isValidServicePhone("112")).toBe(true); expect(isValidServicePhone("136")).toBe(true); + expect(isValidServicePhone("141")).toBe(true); expect(isValidServicePhone("156")).toBe(true); expect(isValidServicePhone("180")).toBe(true); expect(isValidServicePhone("181")).toBe(true); @@ -145,6 +150,13 @@ describe("isValidServicePhone", () => { }); }); +describe("isValidServicePhone with an array of characters", () => { + test("should reject it instead of reading it as the joined string", () => { + // @ts-expect-error: intentionally invalid input + expect(isValidServicePhone("08001234567".match(/\d/g))).toBe(false); + }); +}); + describe("isValidServicePhone types", () => { test("should take a string and return a boolean", () => { expectTypeOf(isValidServicePhone).parameter(0).toEqualTypeOf(); diff --git a/src/is-valid-service-phone/is-valid-service-phone.ts b/src/is-valid-service-phone/is-valid-service-phone.ts index 24bf5b4f..d6f61747 100644 --- a/src/is-valid-service-phone/is-valid-service-phone.ts +++ b/src/is-valid-service-phone/is-valid-service-phone.ts @@ -23,12 +23,17 @@ const UTILITY_CODES: readonly string[] = SERVICE_PHONE_UTILITY_CODES; * - the Códigos Não Geográficos `0300`, `0303`, `0500`, `0800` and `0900`, each followed by * 7 digits (11 in total, the shorter, extinct `0800` + 6 form is rejected); * - the abbreviated `300X` and `400X` numbers, followed by 4 digits, e.g. `3003-1234`. Anatel - * publishes no allocation for these, so the accepted roots are the conventional ones. Only - * `300X` and `400X` are recognised: other "Número Único" carrier prefixes in market use, such - * as `4020` and `4062`, are out of scope and are rejected; + * withdrew the 4-digit codes rather than allocating them (Resolução nº 86/1998 art. 43 I and + * Ato nº 43.151/2004 art. 2º II both ordered them released), so the accepted roots are the + * conventional ones the market settled on. Only `300X` and `400X` are recognised: other + * "Número Único" carrier prefixes in market use, such as `4020` and `4062`, are out of scope + * and are rejected; * - the 3-digit Códigos de Acesso a Serviços de Utilidade Pública that Anatel has designated, - * e.g. `190` and `192`. Undesignated codes in the `1XX` range are rejected. `112` and `911` - * are accepted too: Anatel lists them alongside the `1XX` codes as mobile-only aliases of `190`. + * e.g. `190` and `192`, the consolidated table being the Anexo of Ato nº 43.151/2004. + * Undesignated codes in the `1XX` range are rejected, and so are `112` and `911`: Anatel + * designates neither, and `911` is not even inside the `1N₂N₁` range Resolução nº 749/2022 + * art. 13 destines to public utility services. Handsets route both by GSM convention, which + * is not a numbering designation. * * Only the structure is checked: the number does not have to be assigned to anyone, and the * `0500` rule that encodes a donation amount in the last two digits is not enforced. @@ -45,6 +50,9 @@ const UTILITY_CODES: readonly string[] = SERVICE_PHONE_UTILITY_CODES; * ``` * * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749 + * Resolução Anatel nº 749/2022, arts. 13, 14, 18 and 28. + * @see Official: https://informacoes.anatel.gov.br/legislacao/atos-de-numeracao/2004/1648-ato-43151 + * Ato Anatel nº 43.151/2004, whose Anexo designates the 3-digit public utility codes. */ export const isValidServicePhone = (value: string): boolean => { if (typeof value !== "string") return false; diff --git a/src/is-valid-vin/constants.ts b/src/is-valid-vin/constants.ts index 35726df7..83de7205 100644 --- a/src/is-valid-vin/constants.ts +++ b/src/is-valid-vin/constants.ts @@ -3,12 +3,17 @@ * `I`, `O` and `Q` (dropped to avoid confusion with `1` and `0`), per ISO 3779:2009 structure. * The check digit at the 9th position, its transliteration table and its weighted MOD 11 * algorithm are a North-American requirement (49 CFR 565.15 / SAE J853), not something - * Resolução CONTRAN nº 24/1998 or ABNT NBR 6066 — which define the Brazilian VIN structure — - * mandate; many Brazilian-built VINs do not carry a matching check digit. + * Resolução CONTRAN nº 968/2022 (which replaced Resolução CONTRAN nº 24/1998 from 1 January 2025, + * art. 50, II) or ABNT NBR 6066, which define the Brazilian VIN structure, mandate; many Brazilian-built VINs do not carry a matching check digit. + * The ISO catalogue page sits behind a bot filter and answers HTTP 403 to every non-browser + * client, so it has to be opened in a browser, where it renders the standard's paywalled + * abstract rather than its text. * @see Official: https://www.iso.org/standard/52200.html * @see Official: https://www.ecfr.gov/current/title-49/section-565.15 - * @see Official: https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-Senatran/resolucoes-contran - * @see Based on: https://vpic.nhtsa.dot.gov/api/ + * @see Official: https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes/resolucao9682022.pdf + * Resolução CONTRAN nº 968, de 20 de junho de 2022, art. 2º, I (VIN of 17 characters in three sections) + * and art. 50, II (revocation of Resolução nº 24/1998 from 1 January 2025). + * @see Official: https://vpic.nhtsa.dot.gov/api/ */ export const VIN_LENGTH = 17; diff --git a/src/is-valid-vin/is-valid-vin.test.ts b/src/is-valid-vin/is-valid-vin.test.ts index bfc119a2..30bf44fc 100644 --- a/src/is-valid-vin/is-valid-vin.test.ts +++ b/src/is-valid-vin/is-valid-vin.test.ts @@ -35,6 +35,13 @@ describe("isValidVin", () => { expect(isValidVin("1HGCM82633A004353")).toBe(false); }); + test("when every character is the same, even though the check digit matches", () => { + expect(isValidVin("00000000000000000")).toBe(false); + expect(isValidVin("55555555555555555")).toBe(false); + expect(isValidVin("99999999999999999")).toBe(false); + expect(isValidVin(" 00000000000000000 ")).toBe(false); + }); + test("when it contains the excluded letter I", () => { expect(isValidVin("1HGCM8263IA004352")).toBe(false); }); @@ -71,6 +78,13 @@ describe("isValidVin", () => { expect(isValidVin("1HGCM82633A00435-")).toBe(false); }); + test("when a mask character splits it, since a VIN has no printed grouping", () => { + expect(isValidVin("1HGCM8 2633A004352")).toBe(false); + expect(isValidVin("1HGCM8-2633A004352")).toBe(false); + expect(isValidVin("1HGCM8.2633A004352")).toBe(false); + expect(isValidVin("1HGCM8/2633A004352")).toBe(false); + }); + test("when it is an empty string", () => { expect(isValidVin("")).toBe(false); }); @@ -120,6 +134,10 @@ describe("isValidVin", () => { test("should accept exactly one check character for any body", () => { fc.assert( fc.property(bodies, (body) => { + // A body of a single repeated character can have its one accepted candidate rejected + // as a repeated-character VIN, so it is left to the literal tests above. + fc.pre(new Set(body).size > 1); + const candidates = VIN_CHECK_CHARACTERS.map( (character) => `${body.slice(0, 8)}${character}${body.slice(8)}`, ); diff --git a/src/is-valid-vin/is-valid-vin.ts b/src/is-valid-vin/is-valid-vin.ts index 28fb5239..6d7ee416 100644 --- a/src/is-valid-vin/is-valid-vin.ts +++ b/src/is-valid-vin/is-valid-vin.ts @@ -1,4 +1,5 @@ import { generateChecksum } from "../_internals/generate-checksum/generate-checksum"; +import { isRepeatedDigits } from "../_internals/is-repeated-digits/is-repeated-digits"; import { VIN_CHECK_DIGIT_POSITION, VIN_LENGTH, @@ -12,11 +13,23 @@ import { * Checks the length (17 characters), the excluded letters (`I`, `O`, `Q` are never valid; ISO * 3779:2009 structure) and the check digit at the 9th position, with the check digit and * transliteration computed per 49 CFR 565.15. That 9th-position check digit is a North-American - * requirement (49 CFR 565.15 / SAE J853): Resolução CONTRAN nº 24/1998 and ABNT NBR 6066 define + * requirement (49 CFR 565.15 / SAE J853): Resolução CONTRAN nº 968/2022 (in force since 1 July 2022, revoking Resolução CONTRAN nº 24/1998 from + * 1 January 2025 by its art. 50, II) and ABNT NBR 6066 define * the Brazilian VIN structure but do not mandate it, so many Brazilian-built VINs do not carry * a matching check digit. This function is therefore a North-American-style structural check, * not a universal validator of Brazilian VINs. Case-insensitive and trims surrounding whitespace. * + * A VIN is printed as one unbroken run of 17 characters, so, unlike the documents this package + * masks (`isValidCpf`, `isValidCnpj`, `isValidNfeKey`), it has no group boundary to write a + * separator at and none is accepted: a space, `.`, `-` or `/` among the characters is rejected + * instead of being stripped. + * + * A value whose 17 characters are all the same (`"00000000000000000"`) is rejected even when it + * carries a matching check digit, as every other validator of this package rejects a + * repeated-digit document (`isValidCpf("00000000000")`, `isValidCns`, `isValidCaepf`, + * `isValidCei`): no WMI, VDS and VIS are built out of a single repeated character, and it is what + * a placeholder or a zero-filled field looks like. + * * @param {string} value - The VIN to be validated. * @returns {boolean} True when `value` is a 17 character VIN with a matching check digit. * @@ -26,14 +39,21 @@ import { * isValidVin("1m8gdm9axkp042788"); // true (check digit X, lowercase) * isValidVin("JH4TB2H26CC000000"); // true * isValidVin("1HGCM82633A004353"); // false (bad check digit) + * isValidVin("00000000000000000"); // false (every character the same, though the check digit matches) * isValidVin("1HGCM8263IA004352"); // false (contains the excluded letter I) * isValidVin("1HGCM82633A00435"); // false (16 characters) * ``` * + * The ISO catalogue page sits behind a bot filter and answers HTTP 403 to every non-browser + * client, so it has to be opened in a browser, where it renders the standard's paywalled + * abstract rather than its text. + * * @see Official: https://www.iso.org/standard/52200.html * @see Official: https://www.ecfr.gov/current/title-49/section-565.15 - * @see Official: https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-Senatran/resolucoes-contran - * @see Based on: https://vpic.nhtsa.dot.gov/api/ + * @see Official: https://www.gov.br/transportes/pt-br/assuntos/transito/conteudo-contran/resolucoes/resolucao9682022.pdf + * Resolução CONTRAN nº 968, de 20 de junho de 2022, art. 2º, I (VIN of 17 characters in three sections) + * and art. 50, II (revocation of Resolução nº 24/1998 from 1 January 2025). + * @see Official: https://vpic.nhtsa.dot.gov/api/ */ export const isValidVin = (value: string): boolean => { if (typeof value !== "string") return false; @@ -42,6 +62,8 @@ export const isValidVin = (value: string): boolean => { if (vin.length !== VIN_LENGTH) return false; + if (isRepeatedDigits(vin)) return false; + // Stryker disable next-line StringLiteral: generateChecksum strips this to digits, so it's inert. let translitDigits = ""; diff --git a/src/is-valid-voter-id/is-valid-voter-id.ts b/src/is-valid-voter-id/is-valid-voter-id.ts index 6ffba7d4..f9bbc3e4 100644 --- a/src/is-valid-voter-id/is-valid-voter-id.ts +++ b/src/is-valid-voter-id/is-valid-voter-id.ts @@ -33,6 +33,9 @@ const FORMAT_REGEX = /^[\s.]*\d{4}[\s.]*\d{4}[\s.]*(?:\d[\s.]*)?\d{2}[\s.]*\d{2} * 13-digit São Paulo/Minas Gerais ids are brutils parity, not published by the TSE — siga0984 uses * a different 9-digit rule for the sequential number. * + * The TSE resolution page sits behind a bot filter and answers HTTP 403 to every non-browser + * client, so it has to be opened in a browser. + * * @see Official: https://www.tse.jus.br/legislacao/compilada/res/2021/resolucao-no-23-659-de-26-de-outubro-de-2021 * @see Based on: https://siga0984.wordpress.com/2019/05/01/algoritmos-validacao-de-titulo-de-eleitor/ * @see Based on: https://github.com/brazilian-utils/python/blob/main/brutils/voter_id.py diff --git a/src/parse-boleto/parse-boleto.ts b/src/parse-boleto/parse-boleto.ts index caf9008f..f9f25f4a 100644 --- a/src/parse-boleto/parse-boleto.ts +++ b/src/parse-boleto/parse-boleto.ts @@ -1,6 +1,5 @@ import { ARRECADACAO_LINE_LENGTH, ARRECADACAO_PRODUCT } from "../_internals/constants/arrecadacao"; import { BOLETO_LENGTH } from "../_internals/constants/boleto"; -import { isNullish } from "../_internals/is-nullish/is-nullish"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; /** @@ -21,17 +20,17 @@ import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-d * // "826300000011098800100702024102024000000205104519" * ``` * - * Carta-Circular BCB nº 2.926/2000 specifies the linha digitável fields, the módulo 11 check - * digit (using 1 for remainders 0, 10 and 1) and the fator de vencimento behind the 47 digit - * cobrança bancária slip; the FEBRABAN layout index covers the arrecadação slip. + * Carta-Circular BCB nº 2.926/2000 specifies the linha digitável fields and the módulo 11 + * check digit (using 1 for remainders 0, 10 and 1) of the 47 digit cobrança bancária slip, + * including the position of the fator de vencimento field. The FEBRABAN "Layout Padrão de + * Arrecadação/Recebimento com Utilização do Código de Barras" and the FEBRABAN layout index + * cover the arrecadação slip. * - * @see Official: https://cmsarquivos.febraban.org.br/Arquivos/documentos/PDF/Layout%20-%20C%C3%B3digo%20de%20Barras%20-%20Vers%C3%A3o%208%20-%2011_05_2026.pdf * @see Official: https://www.bcb.gov.br/pre/normativos/c_circ/2000/pdf/c_circ_2926_v1_O.pdf + * @see Official: https://cmsarquivos.febraban.org.br/Arquivos/documentos/PDF/Layout%20-%20C%C3%B3digo%20de%20Barras%20-%20Vers%C3%A3o%208%20-%2011_05_2026.pdf * @see Official: https://portal.febraban.org.br/pagina/3425/33/pt-br/layout-febraban */ export const parseBoleto = (value: string | number): string => { - if (isNullish(value)) return ""; - const digits = sanitizeToDigits(value); return digits.slice( diff --git a/src/parse-caepf/constants.ts b/src/parse-caepf/constants.ts new file mode 100644 index 00000000..95895886 --- /dev/null +++ b/src/parse-caepf/constants.ts @@ -0,0 +1,2 @@ +/** Digits of a CAEPF number, printed as "000.000.000/000-00". */ +export const LENGTH = 14; diff --git a/src/parse-caepf/parse-caepf.test.ts b/src/parse-caepf/parse-caepf.test.ts new file mode 100644 index 00000000..2fb7c947 --- /dev/null +++ b/src/parse-caepf/parse-caepf.test.ts @@ -0,0 +1,58 @@ +import { anyText, anyValue, digitsUpTo } from "../_internals/test/arbitraries"; +import { + expectAlwaysReturnsType, + expectIdempotent, + expectMatchesPattern, + expectRoundTrip, +} from "../_internals/test/properties"; +import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; +import { formatCaepf } from "../format-caepf/format-caepf"; +import { parseCaepf } from "./parse-caepf"; + +describe("parseCaepf", () => { + it("should remove CAEPF mask characters", () => { + expect(parseCaepf("293.118.610/001-84")).toBe("29311861000184"); + }); + + it("should remove non numeric characters", () => { + expect(parseCaepf("293.?ABC118.610/001-84abc")).toBe("29311861000184"); + }); + + it("should ignore digits after the CAEPF length", () => { + expect(parseCaepf("29311861000184999")).toBe("29311861000184"); + }); + + it("should read a number as the string of its digits", () => { + expect(parseCaepf(29_311_861_000_184)).toBe("29311861000184"); + }); + + it("should return an empty string for null", () => { + // @ts-expect-error not a string or number + expect(parseCaepf(null)).toBe(""); + }); + + describe("properties", () => { + test("should return at most the digits of a CAEPF", () => { + expectMatchesPattern(parseCaepf, /^\d{0,14}$/, anyText); + }); + + test("should undo formatCaepf", () => { + expectRoundTrip(formatCaepf, parseCaepf, digitsUpTo(14)); + }); + + test("should be idempotent", () => { + expectIdempotent(parseCaepf, anyText); + }); + + test("should never throw and always return a string", () => { + expectAlwaysReturnsType(parseCaepf, "string", anyValue); + }); + }); +}); + +describe("parseCaepf types", () => { + test("should take a string or number value and return a string", () => { + expectTypeOf(parseCaepf).parameter(0).toEqualTypeOf(); + expectTypeOf(parseCaepf).returns.toEqualTypeOf(); + }); +}); diff --git a/src/parse-caepf/parse-caepf.ts b/src/parse-caepf/parse-caepf.ts new file mode 100644 index 00000000..10a668f7 --- /dev/null +++ b/src/parse-caepf/parse-caepf.ts @@ -0,0 +1,26 @@ +import { isNullish } from "../_internals/is-nullish/is-nullish"; +import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { LENGTH } from "./constants"; + +/** + * Removes CAEPF (Cadastro de Atividade Econômica da Pessoa Física) formatting characters and + * returns only digits. + * + * The number has 14 digits, 12 of base plus the two check digits, which is the length the result + * is capped at; a shorter value passes through as far as it goes. Use `isValidCaepf` to check the + * number itself. + * + * @param {string|number} value - The CAEPF value to be parsed. + * @returns {string} Up to 14 digits, or an empty string when there is no digit at all. + * + * @example + * ```typescript + * parseCaepf("293.118.610/001-84"); // "29311861000184" + * ``` + * + * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/orientacao-tributaria/cadastros/caepf + * The registry's own page at the Receita Federal, which describes the cadastro but does not print + * the mask; the mask is the one the sources cited by `isValidCaepf` agree on. + */ +export const parseCaepf = (value: string | number): string => + isNullish(value) ? "" : sanitizeToDigits(value).slice(0, LENGTH); diff --git a/src/parse-cbo/constants.ts b/src/parse-cbo/constants.ts new file mode 100644 index 00000000..29915d8c --- /dev/null +++ b/src/parse-cbo/constants.ts @@ -0,0 +1,2 @@ +/** Digits of a CBO (Classificação Brasileira de Ocupações) code, printed as "0000-00". */ +export const LENGTH = 6; diff --git a/src/parse-cbo/parse-cbo.test.ts b/src/parse-cbo/parse-cbo.test.ts new file mode 100644 index 00000000..bc6c3a0a --- /dev/null +++ b/src/parse-cbo/parse-cbo.test.ts @@ -0,0 +1,56 @@ +import { anyText, anyValue } from "../_internals/test/arbitraries"; +import { + expectAlwaysReturnsType, + expectIdempotent, + expectMatchesPattern, +} from "../_internals/test/properties"; +import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; +import { parseCbo } from "./parse-cbo"; + +describe("parseCbo", () => { + it("should remove CBO mask characters", () => { + expect(parseCbo("2124-05")).toBe("212405"); + }); + + it("should remove non numeric characters", () => { + expect(parseCbo("21?ABC24-05abc")).toBe("212405"); + }); + + it("should ignore digits after the CBO length", () => { + expect(parseCbo("212405999")).toBe("212405"); + }); + + it("should read a number as the string of its digits", () => { + expect(parseCbo(212_405)).toBe("212405"); + }); + + it("should keep a partial code as written, without padding it", () => { + expect(parseCbo("10205")).toBe("10205"); + }); + + it("should return an empty string for null", () => { + // @ts-expect-error not a string or number + expect(parseCbo(null)).toBe(""); + }); + + describe("properties", () => { + test("should return at most the digits of a CBO code", () => { + expectMatchesPattern(parseCbo, /^\d{0,6}$/, anyText); + }); + + test("should be idempotent", () => { + expectIdempotent(parseCbo, anyText); + }); + + test("should never throw and always return a string", () => { + expectAlwaysReturnsType(parseCbo, "string", anyValue); + }); + }); +}); + +describe("parseCbo types", () => { + test("should take a string or number value and return a string", () => { + expectTypeOf(parseCbo).parameter(0).toEqualTypeOf(); + expectTypeOf(parseCbo).returns.toEqualTypeOf(); + }); +}); diff --git a/src/parse-cbo/parse-cbo.ts b/src/parse-cbo/parse-cbo.ts new file mode 100644 index 00000000..86eece2f --- /dev/null +++ b/src/parse-cbo/parse-cbo.ts @@ -0,0 +1,26 @@ +import { isNullish } from "../_internals/is-nullish/is-nullish"; +import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { LENGTH } from "./constants"; + +/** + * Removes CBO (Classificação Brasileira de Ocupações) formatting characters and returns only + * digits. + * + * An occupation code has 6 digits, which is the length the result is capped at; a shorter value + * passes through as far as it goes and is never left padded, so the leading zero of a code such + * as `010205` has to be written out. Use `getCbo` or `isValidCbo`, which do pad a bare numeric + * code, to look an occupation up. + * + * @param {string|number} value - The CBO code to be parsed. + * @returns {string} Up to 6 digits, or an empty string when there is no digit at all. + * + * @example + * ```typescript + * parseCbo("2124-05"); // "212405" + * ``` + * + * @see Official: https://www.gov.br/trabalho-e-emprego/pt-br/assuntos/cbo/servicos/downloads/cbo2002-ocupacao.csv + * The CBO 2002 occupation table, as published by the Ministério do Trabalho e Emprego. + */ +export const parseCbo = (value: string | number): string => + isNullish(value) ? "" : sanitizeToDigits(value).slice(0, LENGTH); diff --git a/src/parse-cei/constants.ts b/src/parse-cei/constants.ts new file mode 100644 index 00000000..a063dbe1 --- /dev/null +++ b/src/parse-cei/constants.ts @@ -0,0 +1,2 @@ +/** Digits of a CEI (Cadastro Específico do INSS) number, printed as "00.000.00000/00". */ +export const LENGTH = 12; diff --git a/src/parse-cei/parse-cei.test.ts b/src/parse-cei/parse-cei.test.ts new file mode 100644 index 00000000..393f687f --- /dev/null +++ b/src/parse-cei/parse-cei.test.ts @@ -0,0 +1,58 @@ +import { anyText, anyValue, digitsUpTo } from "../_internals/test/arbitraries"; +import { + expectAlwaysReturnsType, + expectIdempotent, + expectMatchesPattern, + expectRoundTrip, +} from "../_internals/test/properties"; +import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; +import { formatCei } from "../format-cei/format-cei"; +import { parseCei } from "./parse-cei"; + +describe("parseCei", () => { + it("should remove CEI mask characters", () => { + expect(parseCei("27.729.71181/87")).toBe("277297118187"); + }); + + it("should remove non numeric characters", () => { + expect(parseCei("27.?ABC729.71181/87abc")).toBe("277297118187"); + }); + + it("should ignore digits after the CEI length", () => { + expect(parseCei("277297118187999")).toBe("277297118187"); + }); + + it("should read a number as the string of its digits", () => { + expect(parseCei(277_297_118_187)).toBe("277297118187"); + }); + + it("should return an empty string for null", () => { + // @ts-expect-error not a string or number + expect(parseCei(null)).toBe(""); + }); + + describe("properties", () => { + test("should return at most the digits of a CEI", () => { + expectMatchesPattern(parseCei, /^\d{0,12}$/, anyText); + }); + + test("should undo formatCei", () => { + expectRoundTrip(formatCei, parseCei, digitsUpTo(12)); + }); + + test("should be idempotent", () => { + expectIdempotent(parseCei, anyText); + }); + + test("should never throw and always return a string", () => { + expectAlwaysReturnsType(parseCei, "string", anyValue); + }); + }); +}); + +describe("parseCei types", () => { + test("should take a string or number value and return a string", () => { + expectTypeOf(parseCei).parameter(0).toEqualTypeOf(); + expectTypeOf(parseCei).returns.toEqualTypeOf(); + }); +}); diff --git a/src/parse-cei/parse-cei.ts b/src/parse-cei/parse-cei.ts new file mode 100644 index 00000000..7e714966 --- /dev/null +++ b/src/parse-cei/parse-cei.ts @@ -0,0 +1,25 @@ +import { isNullish } from "../_internals/is-nullish/is-nullish"; +import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { LENGTH } from "./constants"; + +/** + * Removes CEI (Cadastro Específico do INSS) formatting characters and returns only digits. + * + * The numbering has 12 digits, 11 of base and one check digit, which is the length the result is + * capped at; a shorter value passes through as far as it goes, so the mask of an input still + * being typed can be stripped with it. Use `isValidCei` to check the number itself. + * + * @param {string|number} value - The CEI value to be parsed. + * @returns {string} Up to 12 digits, or an empty string when there is no digit at all. + * + * @example + * ```typescript + * parseCei("27.729.71181/87"); // "277297118187" + * ``` + * + * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/orientacao-tributaria/cadastros/cno + * The registry's own page at the Receita Federal, which describes the cadastro but does not print + * the mask; the mask is the one the reference implementations cited by `isValidCei` agree on. + */ +export const parseCei = (value: string | number): string => + isNullish(value) ? "" : sanitizeToDigits(value).slice(0, LENGTH); diff --git a/src/parse-certidao/constants.ts b/src/parse-certidao/constants.ts deleted file mode 100644 index 1d682790..00000000 --- a/src/parse-certidao/constants.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * The nine books (tipo do livro) a matrícula de registro civil can point to, in the order of - * the codes 1 to 9: Livro A (nascimento), Livro B (casamento), Livro B Auxiliar (casamento - * religioso com efeito civil), Livro C (óbito), Livro C Auxiliar (natimorto), Livro D - * (proclamas), Livro E (demais atos), Livro E desdobrado para emancipações and Livro E - * desdobrado para interdições. - * - * The in-force art. 473, V of the Código Nacional de Normas da Corregedoria Nacional de Justiça - * lists only the codes 1 to 7. The codes 8 (emancipação) and 9 (interdição) come from the Anexo - * IV of the revoked Provimento CNJ nº 63/2017 and are kept because matrículas issued under it - * are still in circulation. - * - * @see Official: https://atos.cnj.jus.br/atos/detalhar/5243 Código Nacional de Normas da - * Corregedoria Nacional de Justiça - Foro Extrajudicial (Provimento CNJ nº 149/2023), art. 473 - * in the wording of the Provimento CN nº 182, de 17/09/2024: the in-force layout of the 32 - * digit matrícula. - * @see Official: https://atos.cnj.jus.br/atos/detalhar/1311 Provimento CNJ nº 2, de 27/04/2009, - * which instituted the modelos únicos de certidão and the matrícula (revoked; historical). - * @see Based on: http://ghiorzi.org/DVnew.htm Description of the nine books and their codes. - * @see Based on: https://github.com/Casilhero/brazilian-validators/blob/main/src/Support/CertidaoInfo.php - * Reference implementation agreeing on the same nine books, in the same order. - */ -export const CERTIDAO_TYPES = [ - "birth", - "marriage", - "religious-marriage", - "death", - "stillbirth", - "banns", - "other", - "emancipation", - "interdiction", -] as const; diff --git a/src/parse-certidao/parse-certidao.test.ts b/src/parse-certidao/parse-certidao.test.ts index 984367f7..611c03a7 100644 --- a/src/parse-certidao/parse-certidao.test.ts +++ b/src/parse-certidao/parse-certidao.test.ts @@ -1,208 +1,65 @@ -import * as fc from "fast-check"; - -import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; -import { CERTIDAO_TYPES } from "./constants"; -import { parseCertidao, type Certidao, type CertidaoType } from "./parse-certidao"; - -const findMatricula = (base: string): string => { - for (let pair = 0; pair < 100; pair++) { - const value = `${base}${String(pair).padStart(2, "0")}`; - - if (parseCertidao(value) !== null) return value; - } - - return ""; -}; +import { CERTIDAO_LENGTH } from "../_internals/constants/certidao"; +import { anyText, anyValue, digitsUpTo } from "../_internals/test/arbitraries"; +import { + expectAlwaysReturnsType, + expectIdempotent, + expectMatchesPattern, + expectRoundTrip, +} from "../_internals/test/properties"; +import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; +import { formatCertidao } from "../format-certidao/format-certidao"; +import { parseCertidao } from "./parse-certidao"; describe("parseCertidao", () => { - describe("should return null", () => { - test("when it is null", () => { - // @ts-expect-error: intentionally invalid input - expect(parseCertidao(null)).toBeNull(); - }); - - test("when it is undefined", () => { - // @ts-expect-error: intentionally invalid input - expect(parseCertidao()).toBeNull(); - }); - - test("when it is an empty string", () => { - expect(parseCertidao("")).toBeNull(); - }); - - test("when the check digits do not match", () => { - expect(parseCertidao("10453901552013100012021000012322")).toBeNull(); - }); - - test("when the matrícula is otherwise invalid", () => { - expect(parseCertidao("not-a-matricula")).toBeNull(); - }); - - test("when the book code is 0, outside the nine books of the Provimento", () => { - expect(parseCertidao("10453901552013000012021000012387")).toBeNull(); - }); - - test("when the serviço is not the 55 of art. 473, III, even with matching check digits", () => { - expect(parseCertidao("09400301542011100110002005191744")).toBeNull(); - }); - - test("when it is a number, which cannot carry the 32 significant digits of a matrícula", () => { - // @ts-expect-error: intentionally invalid input - expect(parseCertidao(1_045_390_155)).toBeNull(); - }); + it("should remove the matrícula mask characters", () => { + expect(parseCertidao("104539 01 55 2013 1 00012 021 0000123 21")).toBe( + "10453901552013100012021000012321", + ); }); - describe("should return the parsed matrícula", () => { - test("for 104539.01.55.2013.1.00012.021.0000123-21, the worked example of ghiorzi.org/DVnew.htm", () => { - expect(parseCertidao("104539 01 55 2013 1 00012 021 0000123 21")).toEqual({ - registryCns: "104539", - acervo: "01", - service: "55", - year: 2013, - type: "birth", - typeCode: 1, - book: "00012", - page: "021", - term: "0000123", - checkDigits: "21", - }); - }); - - test("for 094300 01 55 2010 1 00020 112 0000120-87 (klawdyo/validation-br certidao.spec.ts)", () => { - expect(parseCertidao("094300 01 55 2010 1 00020 112 0000120-87")).toEqual({ - registryCns: "094300", - acervo: "01", - service: "55", - year: 2010, - type: "birth", - typeCode: 1, - book: "00020", - page: "112", - term: "0000120", - checkDigits: "87", - }); - }); - - test("for a marriage act, book code 2", () => { - expect(parseCertidao("10453901552013200012021000012376")?.type).toBe("marriage"); - }); - - test("for a religious marriage with civil effect, book code 3", () => { - expect(parseCertidao("10453901552013300012021000012310")?.type).toBe("religious-marriage"); - }); - - test("for a death act, book code 4", () => { - expect(parseCertidao("10453901552013400012021000012365")?.type).toBe("death"); - }); - - test("for a stillbirth act, book code 5", () => { - expect(parseCertidao("10453901552013500012021000012301")?.type).toBe("stillbirth"); - }); - - test("for a proclamas act, book code 6", () => { - expect(parseCertidao("10453901552013600012021000012354")?.type).toBe("banns"); - }); - - test("for the other acts of Livro E, book code 7", () => { - expect(parseCertidao("10453901552013700012021000012315")?.type).toBe("other"); - }); + it("should remove non numeric characters", () => { + expect(parseCertidao("104539.01.55.2013.1.00012.021.0000123-21abc")).toBe( + "10453901552013100012021000012321", + ); + }); - test("for an emancipation act, book code 8", () => { - expect(parseCertidao("10453901552013800012021000012343")?.type).toBe("emancipation"); - }); + it(`should ignore digits after the matrícula length (${CERTIDAO_LENGTH})`, () => { + expect(parseCertidao("10453901552013100012021000012321999")).toBe( + "10453901552013100012021000012321", + ); + }); - test("for an interdiction act, book code 9", () => { - expect(parseCertidao("10453901552013900012021000012398")?.type).toBe("interdiction"); - }); + it("should keep a partial matrícula as written", () => { + expect(parseCertidao("104539 01")).toBe("10453901"); + }); - test("for a matrícula whose first modulus 11 remainder is 10 (826683 01 55 2015 2 09245 842 9990114 18)", () => { - expect(parseCertidao("82668301552015209245842999011418")).toEqual({ - registryCns: "826683", - acervo: "01", - service: "55", - year: 2015, - type: "marriage", - typeCode: 2, - book: "09245", - page: "842", - term: "9990114", - checkDigits: "18", - }); - }); + it("should return an empty string for null", () => { + // @ts-expect-error not a string or number + expect(parseCertidao(null)).toBe(""); }); describe("properties", () => { - const parts = fc.tuple( - fc.stringMatching(/^[0-9]{6}$/), - fc.stringMatching(/^[0-9]{2}$/), - fc.constant("55"), - fc.integer({ min: 1000, max: 9999 }), - fc.integer({ min: 1, max: 9 }), - fc.stringMatching(/^[0-9]{5}$/), - fc.stringMatching(/^[0-9]{3}$/), - fc.stringMatching(/^[0-9]{7}$/), - ); - - test("should give back every field of a valid matrícula", () => { - fc.assert( - fc.property(parts, (fields) => { - const [registryCns, acervo, service, year, typeCode, book, page, term] = fields; - const registry = `${registryCns}${acervo}${service}${year}${typeCode}`; - const value = findMatricula(`${registry}${book}${page}${term}`); - const parsed = parseCertidao(value); + test("should return at most the digits of a matrícula", () => { + expectMatchesPattern(parseCertidao, /^\d{0,32}$/, anyText); + }); - expect(parsed?.registryCns).toBe(registryCns); - expect(parsed?.acervo).toBe(acervo); - expect(parsed?.service).toBe(service); - expect(parsed?.year).toBe(year); - expect(parsed?.typeCode).toBe(typeCode); - expect(parsed?.book).toBe(book); - expect(parsed?.page).toBe(page); - expect(parsed?.term).toBe(term); - expect(parsed?.checkDigits).toBe(value.slice(30)); - expect(parsed?.type).toBe(CERTIDAO_TYPES[typeCode - 1]); - }), - ); + test("should undo formatCertidao", () => { + expectRoundTrip(formatCertidao, parseCertidao, digitsUpTo(CERTIDAO_LENGTH)); }); - test("should never throw and always return a matrícula or null", () => { - fc.assert( - fc.property(fc.anything(), (value) => { - const parsed = parseCertidao(value as string); + test("should be idempotent", () => { + expectIdempotent(parseCertidao, anyText); + }); - expect(parsed === null || typeof parsed.registryCns === "string").toBe(true); - }), - ); + test("should never throw and always return a string", () => { + expectAlwaysReturnsType(parseCertidao, "string", anyValue); }); }); }); describe("parseCertidao types", () => { - test("should take a string and return a Certidao or null", () => { - expectTypeOf(parseCertidao).parameter(0).toEqualTypeOf(); - expectTypeOf(parseCertidao).returns.toEqualTypeOf(); - expectTypeOf().toEqualTypeOf<{ - registryCns: string; - acervo: string; - service: string; - year: number; - type: CertidaoType; - typeCode: number; - book: string; - page: string; - term: string; - checkDigits: string; - }>(); - expectTypeOf().toEqualTypeOf< - | "birth" - | "marriage" - | "religious-marriage" - | "death" - | "stillbirth" - | "banns" - | "other" - | "emancipation" - | "interdiction" - >(); + test("should take a string or number value and return a string", () => { + expectTypeOf(parseCertidao).parameter(0).toEqualTypeOf(); + expectTypeOf(parseCertidao).returns.toEqualTypeOf(); }); }); diff --git a/src/parse-certidao/parse-certidao.ts b/src/parse-certidao/parse-certidao.ts index 977e19d5..66892832 100644 --- a/src/parse-certidao/parse-certidao.ts +++ b/src/parse-certidao/parse-certidao.ts @@ -1,106 +1,32 @@ -import { CERTIDAO_BASE_LENGTH } from "../_internals/constants/certidao"; +import { CERTIDAO_LENGTH } from "../_internals/constants/certidao"; +import { isNullish } from "../_internals/is-nullish/is-nullish"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; -import { isValidCertidao } from "../is-valid-certidao/is-valid-certidao"; -import { CERTIDAO_TYPES } from "./constants"; /** - * The nine books (tipo do livro) a matrícula de registro civil can point to, in the order of the - * codes 1 to 9. `parseCertidao` names the book of a matrícula with one of these, and - * `isValidCertidao` accepts a list of them. + * Removes the formatting of the matrícula of a certidão de registro civil and returns only + * digits. * - * The in-force art. 473, V of the Código Nacional de Normas da Corregedoria Nacional de Justiça - * lists only the codes 1 to 7. The codes 8 (`"emancipation"`) and 9 (`"interdiction"`) come from - * the Anexo IV of the revoked Provimento CNJ nº 63/2017 and are kept because matrículas issued - * under it are still in circulation. - */ -export type CertidaoType = - | "birth" - | "marriage" - | "religious-marriage" - | "death" - | "stillbirth" - | "banns" - | "other" - | "emancipation" - | "interdiction"; - -/** The fields `parseCertidao` reads out of the matrícula of a certidão de registro civil. */ -export type Certidao = { - /** The 6 digit CNS (Código Nacional de Serventia) of the serventia that issued the act. */ - registryCns: string; - /** Acervo the book belongs to: "01" the serventia's own, "02" a collection it absorbed. */ - acervo: string; - /** Service rendered by the serventia, always "55", the registro civil das pessoas naturais. */ - service: string; - /** Four digit year the act was recorded. */ - year: number; - /** The book the act belongs to, as an English name. */ - type: CertidaoType; - /** Raw book code, 1 to 9, as printed in the fifteenth position of the matrícula. */ - typeCode: number; - /** The 5 digit book (livro) number, zero padded. */ - book: string; - /** The 3 digit page (folha) number, zero padded. */ - page: string; - /** The 7 digit term (termo) number, zero padded. */ - term: string; - /** The 2 modulus 11 check digits of the matrícula. */ - checkDigits: string; -}; - -/** - * Parses the matrícula of a certidão de registro civil into its fields. - * - * Accepts the same input forms as `isValidCertidao` and returns `null` when the matrícula is - * not valid, which includes a serviço other than the `55` art. 473, III fixes for the registro - * civil das pessoas naturais, and a book code that is not one of the nine books defined by the - * Provimento, since an unknown book cannot be named. + * The matrícula has 32 digits, which is the length the result is capped at; a shorter value + * passes through as far as it goes, so the mask of an input still being typed can be stripped + * with it. This only takes the mask off: use `isValidCertidao` to check the matrícula and + * `getCertidaoInfo` to read its fields. * - * Only a string is accepted: the 32 digits of a matrícula are more than a JavaScript number can - * hold, so a numeric argument always gives `null` instead of being read as a rounded value. - * - * @param {string} value - The matrícula value to be parsed. - * @returns {Certidao | null} The parsed matrícula, or `null` when it is not valid. + * @param {string|number} value - The matrícula value to be parsed. + * @returns {string} Up to 32 digits, or an empty string when there is no digit at all. * * @example * ```typescript * parseCertidao("104539 01 55 2013 1 00012 021 0000123 21"); - * // { registryCns: "104539", acervo: "01", service: "55", year: 2013, type: "birth", - * // typeCode: 1, book: "00012", page: "021", term: "0000123", checkDigits: "21" } - * - * parseCertidao("invalid"); // null + * // "10453901552013100012021000012321" * ``` * - * @see Official: https://atos.cnj.jus.br/atos/detalhar/5243 Código Nacional de Normas da - * Corregedoria Nacional de Justiça - Foro Extrajudicial (Provimento CNJ nº 149/2023), art. 473 - * in the wording of the Provimento CN nº 182, de 17/09/2024: the in-force layout of the 32 + * @see Official: https://atos.cnj.jus.br/atos/detalhar/5243 + * Código Nacional de Normas da Corregedoria Nacional de Justiça - Foro Extrajudicial (Provimento + * CNJ nº 149/2023), art. 473: the in-force 6 + 2 + 2 + 4 + 1 + 5 + 3 + 7 + 2 layout of the 32 * digit matrícula. - * @see Official: https://atos.cnj.jus.br/atos/detalhar/1311 Provimento CNJ nº 2, de 27/04/2009, - * which instituted the modelos únicos de certidão and the matrícula (revoked; historical). - * @see Based on: http://ghiorzi.org/DVnew.htm Worked example of the two check digits - * (sums 288 and 309). - * @see Based on: https://github.com/klawdyo/validation-br/blob/feat-certidao/src/certidao.ts - * Reference implementation, and the source of the matrículas used as test vectors. - * @see Based on: https://github.com/geekcom/validator-docs/blob/master/src/validator-docs/Rules/Certidao.php - * Third reference implementation agreeing on the weights and on the remainder of 10 read as 1. + * @see Official: https://atos.cnj.jus.br/atos/detalhar/1310 + * Provimento CNJ nº 3, de 17/11/2009, art. 7º, where that matrícula first got the same digit + * structure (revoked; historical). */ -export const parseCertidao = (value: string): Certidao | null => { - if (!isValidCertidao(value)) return null; - - const digits = sanitizeToDigits(value); - const typeCode = digits.charCodeAt(14) - 48; - const type = CERTIDAO_TYPES[typeCode - 1]; - - return { - registryCns: digits.slice(0, 6), - acervo: digits.slice(6, 8), - service: digits.slice(8, 10), - year: Number(digits.slice(10, 14)), - type, - typeCode, - book: digits.slice(15, 20), - page: digits.slice(20, 23), - term: digits.slice(23, CERTIDAO_BASE_LENGTH), - checkDigits: digits.slice(CERTIDAO_BASE_LENGTH), - }; -}; +export const parseCertidao = (value: string | number): string => + isNullish(value) ? "" : sanitizeToDigits(value).slice(0, CERTIDAO_LENGTH); diff --git a/src/parse-cfop/constants.ts b/src/parse-cfop/constants.ts new file mode 100644 index 00000000..4690ce14 --- /dev/null +++ b/src/parse-cfop/constants.ts @@ -0,0 +1,2 @@ +/** Digits of a CFOP (Código Fiscal de Operações e Prestações) code, printed as "0.000". */ +export const LENGTH = 4; diff --git a/src/parse-cfop/parse-cfop.test.ts b/src/parse-cfop/parse-cfop.test.ts new file mode 100644 index 00000000..aec7beeb --- /dev/null +++ b/src/parse-cfop/parse-cfop.test.ts @@ -0,0 +1,52 @@ +import { anyText, anyValue } from "../_internals/test/arbitraries"; +import { + expectAlwaysReturnsType, + expectIdempotent, + expectMatchesPattern, +} from "../_internals/test/properties"; +import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; +import { parseCfop } from "./parse-cfop"; + +describe("parseCfop", () => { + it("should remove CFOP mask characters", () => { + expect(parseCfop("5.102")).toBe("5102"); + }); + + it("should remove non numeric characters", () => { + expect(parseCfop("5?ABC.102abc")).toBe("5102"); + }); + + it("should ignore digits after the CFOP length", () => { + expect(parseCfop("5102999")).toBe("5102"); + }); + + it("should read a number as the string of its digits", () => { + expect(parseCfop(5102)).toBe("5102"); + }); + + it("should return an empty string for null", () => { + // @ts-expect-error not a string or number + expect(parseCfop(null)).toBe(""); + }); + + describe("properties", () => { + test("should return at most the digits of a CFOP code", () => { + expectMatchesPattern(parseCfop, /^\d{0,4}$/, anyText); + }); + + test("should be idempotent", () => { + expectIdempotent(parseCfop, anyText); + }); + + test("should never throw and always return a string", () => { + expectAlwaysReturnsType(parseCfop, "string", anyValue); + }); + }); +}); + +describe("parseCfop types", () => { + test("should take a string or number value and return a string", () => { + expectTypeOf(parseCfop).parameter(0).toEqualTypeOf(); + expectTypeOf(parseCfop).returns.toEqualTypeOf(); + }); +}); diff --git a/src/parse-cfop/parse-cfop.ts b/src/parse-cfop/parse-cfop.ts new file mode 100644 index 00000000..077f9ced --- /dev/null +++ b/src/parse-cfop/parse-cfop.ts @@ -0,0 +1,26 @@ +import { isNullish } from "../_internals/is-nullish/is-nullish"; +import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { LENGTH } from "./constants"; + +/** + * Removes CFOP (Código Fiscal de Operações e Prestações) formatting characters and returns only + * digits. + * + * A code has 4 digits, which is the length the result is capped at; a shorter value passes + * through as far as it goes. No CFOP code starts with a zero, its first digit is the operation + * group from 1 to 7, so nothing is ever padded here. Use `getCfop` or `isValidCfop` to look a + * code up in the official table. + * + * @param {string|number} value - The CFOP code to be parsed. + * @returns {string} Up to 4 digits, or an empty string when there is no digit at all. + * + * @example + * ```typescript + * parseCfop("5.102"); // "5102" + * ``` + * + * @see Official: https://www.confaz.fazenda.gov.br/legislacao/ajustes/sinief/cfop_cvsn_1-6.24 + * Consolidated Anexo II of Convênio SINIEF s/nº 1970, which prints the codes in the "N.NNN" form. + */ +export const parseCfop = (value: string | number): string => + isNullish(value) ? "" : sanitizeToDigits(value).slice(0, LENGTH); diff --git a/src/parse-cnae/constants.ts b/src/parse-cnae/constants.ts new file mode 100644 index 00000000..04d0bc61 --- /dev/null +++ b/src/parse-cnae/constants.ts @@ -0,0 +1,2 @@ +/** Digits of a complete CNAE subclass code, printed as "0000-0/00". */ +export const LENGTH = 7; diff --git a/src/parse-cnae/parse-cnae.test.ts b/src/parse-cnae/parse-cnae.test.ts new file mode 100644 index 00000000..00a41e6a --- /dev/null +++ b/src/parse-cnae/parse-cnae.test.ts @@ -0,0 +1,62 @@ +import { anyText, anyValue, digitsUpTo } from "../_internals/test/arbitraries"; +import { + expectAlwaysReturnsType, + expectIdempotent, + expectMatchesPattern, + expectRoundTrip, +} from "../_internals/test/properties"; +import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; +import { formatCnae } from "../format-cnae/format-cnae"; +import { parseCnae } from "./parse-cnae"; + +describe("parseCnae", () => { + it("should remove CNAE mask characters", () => { + expect(parseCnae("6201-5/01")).toBe("6201501"); + }); + + it("should remove non numeric characters", () => { + expect(parseCnae("62?ABC01-5/01abc")).toBe("6201501"); + }); + + it("should ignore digits after the CNAE length", () => { + expect(parseCnae("6201501999")).toBe("6201501"); + }); + + it("should read a number as the string of its digits", () => { + expect(parseCnae(6_201_501)).toBe("6201501"); + }); + + it("should keep a partial code as written, without padding it", () => { + expect(parseCnae("62")).toBe("62"); + }); + + it("should return an empty string for null", () => { + // @ts-expect-error not a string or number + expect(parseCnae(null)).toBe(""); + }); + + describe("properties", () => { + test("should return at most the digits of a CNAE subclass", () => { + expectMatchesPattern(parseCnae, /^\d{0,7}$/, anyText); + }); + + test("should undo formatCnae", () => { + expectRoundTrip(formatCnae, parseCnae, digitsUpTo(7)); + }); + + test("should be idempotent", () => { + expectIdempotent(parseCnae, anyText); + }); + + test("should never throw and always return a string", () => { + expectAlwaysReturnsType(parseCnae, "string", anyValue); + }); + }); +}); + +describe("parseCnae types", () => { + test("should take a string or number value and return a string", () => { + expectTypeOf(parseCnae).parameter(0).toEqualTypeOf(); + expectTypeOf(parseCnae).returns.toEqualTypeOf(); + }); +}); diff --git a/src/parse-cnae/parse-cnae.ts b/src/parse-cnae/parse-cnae.ts new file mode 100644 index 00000000..aea5a35b --- /dev/null +++ b/src/parse-cnae/parse-cnae.ts @@ -0,0 +1,25 @@ +import { isNullish } from "../_internals/is-nullish/is-nullish"; +import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { LENGTH } from "./constants"; + +/** + * Removes CNAE (Classificação Nacional de Atividades Econômicas) formatting characters and + * returns only digits. + * + * A complete subclass code has 7 digits, which is the length the result is capped at; a shorter + * value (a division, a group or a class still being typed) passes through as far as it goes and + * is never left padded, so the leading zeros a code carries have to be written out. Use + * `getCnae` or `isValidCnae`, which do pad a bare numeric code, to look a code up. + * + * @param {string|number} value - The CNAE code to be parsed. + * @returns {string} Up to 7 digits, or an empty string when there is no digit at all. + * + * @example + * ```typescript + * parseCnae("6201-5/01"); // "6201501" + * ``` + * + * @see Official: https://servicodados.ibge.gov.br/api/v2/cnae/subclasses + */ +export const parseCnae = (value: string | number): string => + isNullish(value) ? "" : sanitizeToDigits(value).slice(0, LENGTH); diff --git a/src/parse-cno/constants.ts b/src/parse-cno/constants.ts new file mode 100644 index 00000000..5aac1b3c --- /dev/null +++ b/src/parse-cno/constants.ts @@ -0,0 +1,2 @@ +/** Digits of a CNO (Cadastro Nacional de Obras) number, printed as "00.000.00000/00". */ +export const LENGTH = 12; diff --git a/src/parse-cno/parse-cno.test.ts b/src/parse-cno/parse-cno.test.ts new file mode 100644 index 00000000..deb7b399 --- /dev/null +++ b/src/parse-cno/parse-cno.test.ts @@ -0,0 +1,58 @@ +import { anyText, anyValue, digitsUpTo } from "../_internals/test/arbitraries"; +import { + expectAlwaysReturnsType, + expectIdempotent, + expectMatchesPattern, + expectRoundTrip, +} from "../_internals/test/properties"; +import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; +import { formatCno } from "../format-cno/format-cno"; +import { parseCno } from "./parse-cno"; + +describe("parseCno", () => { + it("should remove CNO mask characters", () => { + expect(parseCno("11.113.01373/68")).toBe("111130137368"); + }); + + it("should remove non numeric characters", () => { + expect(parseCno("11.?ABC113.01373/68abc")).toBe("111130137368"); + }); + + it("should ignore digits after the CNO length", () => { + expect(parseCno("111130137368999")).toBe("111130137368"); + }); + + it("should read a number as the string of its digits", () => { + expect(parseCno(111_130_137_368)).toBe("111130137368"); + }); + + it("should return an empty string for null", () => { + // @ts-expect-error not a string or number + expect(parseCno(null)).toBe(""); + }); + + describe("properties", () => { + test("should return at most the digits of a CNO", () => { + expectMatchesPattern(parseCno, /^\d{0,12}$/, anyText); + }); + + test("should undo formatCno", () => { + expectRoundTrip(formatCno, parseCno, digitsUpTo(12)); + }); + + test("should be idempotent", () => { + expectIdempotent(parseCno, anyText); + }); + + test("should never throw and always return a string", () => { + expectAlwaysReturnsType(parseCno, "string", anyValue); + }); + }); +}); + +describe("parseCno types", () => { + test("should take a string or number value and return a string", () => { + expectTypeOf(parseCno).parameter(0).toEqualTypeOf(); + expectTypeOf(parseCno).returns.toEqualTypeOf(); + }); +}); diff --git a/src/parse-cno/parse-cno.ts b/src/parse-cno/parse-cno.ts new file mode 100644 index 00000000..bc9c49a2 --- /dev/null +++ b/src/parse-cno/parse-cno.ts @@ -0,0 +1,25 @@ +import { isNullish } from "../_internals/is-nullish/is-nullish"; +import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { LENGTH } from "./constants"; + +/** + * Removes CNO (Cadastro Nacional de Obras) formatting characters and returns only digits. + * + * The CNO replaced the CEI for construction works and kept its 12 digit numbering, so the result + * is capped at the same length; a shorter value passes through as far as it goes. Use + * `isValidCno` to check the number itself. + * + * @param {string|number} value - The CNO value to be parsed. + * @returns {string} Up to 12 digits, or an empty string when there is no digit at all. + * + * @example + * ```typescript + * parseCno("11.113.01373/68"); // "111130137368" + * ``` + * + * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/orientacao-tributaria/cadastros/cno + * The registry's own page at the Receita Federal, which describes the cadastro but does not print + * the mask; the mask is the one the reference implementations cited by `isValidCno` agree on. + */ +export const parseCno = (value: string | number): string => + isNullish(value) ? "" : sanitizeToDigits(value).slice(0, LENGTH); diff --git a/src/parse-cns/constants.ts b/src/parse-cns/constants.ts new file mode 100644 index 00000000..f2b4dcc9 --- /dev/null +++ b/src/parse-cns/constants.ts @@ -0,0 +1,2 @@ +/** Digits of a CNS (Cartão Nacional de Saúde) number, printed as "000 0000 0000 0000". */ +export const LENGTH = 15; diff --git a/src/parse-cns/parse-cns.test.ts b/src/parse-cns/parse-cns.test.ts new file mode 100644 index 00000000..61478897 --- /dev/null +++ b/src/parse-cns/parse-cns.test.ts @@ -0,0 +1,58 @@ +import { anyText, anyValue, digitsUpTo } from "../_internals/test/arbitraries"; +import { + expectAlwaysReturnsType, + expectIdempotent, + expectMatchesPattern, + expectRoundTrip, +} from "../_internals/test/properties"; +import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; +import { formatCns } from "../format-cns/format-cns"; +import { parseCns } from "./parse-cns"; + +describe("parseCns", () => { + it("should remove CNS mask characters", () => { + expect(parseCns("123 4567 8901 0000")).toBe("123456789010000"); + }); + + it("should remove non numeric characters", () => { + expect(parseCns("123.?ABC4567 8901-0000abc")).toBe("123456789010000"); + }); + + it("should ignore digits after the CNS length", () => { + expect(parseCns("123456789010000999")).toBe("123456789010000"); + }); + + it("should read a number as the string of its digits", () => { + expect(parseCns(123_456_789_010_000)).toBe("123456789010000"); + }); + + it("should return an empty string for null", () => { + // @ts-expect-error not a string or number + expect(parseCns(null)).toBe(""); + }); + + describe("properties", () => { + test("should return at most the digits of a CNS", () => { + expectMatchesPattern(parseCns, /^\d{0,15}$/, anyText); + }); + + test("should undo formatCns", () => { + expectRoundTrip(formatCns, parseCns, digitsUpTo(15)); + }); + + test("should be idempotent", () => { + expectIdempotent(parseCns, anyText); + }); + + test("should never throw and always return a string", () => { + expectAlwaysReturnsType(parseCns, "string", anyValue); + }); + }); +}); + +describe("parseCns types", () => { + test("should take a string or number value and return a string", () => { + expectTypeOf(parseCns).parameter(0).toEqualTypeOf(); + expectTypeOf(parseCns).returns.toEqualTypeOf(); + }); +}); diff --git a/src/parse-cns/parse-cns.ts b/src/parse-cns/parse-cns.ts new file mode 100644 index 00000000..3700d8f4 --- /dev/null +++ b/src/parse-cns/parse-cns.ts @@ -0,0 +1,27 @@ +import { isNullish } from "../_internals/is-nullish/is-nullish"; +import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { LENGTH } from "./constants"; + +/** + * Removes CNS (Cartão Nacional de Saúde) formatting characters and returns only digits. + * + * The number the ANVISA and DATASUS routines check has 15 digits, the length the result is + * capped at; a shorter value passes through as far as it goes, so the parser can strip the mask + * off an input still being typed. Use `isValidCns` to check the number itself. + * + * @param {string|number} value - The CNS value to be parsed. + * @returns {string} Up to 15 digits, or an empty string when there is no digit at all. + * + * @example + * ```typescript + * parseCns("123 4567 8901 0000"); // "123456789010000" + * ``` + * + * @see Official: https://rni-docs.anvisa.gov.br/docs/regras_gerais/validacoes/validacaoCNS/ + * ANVISA's validation routines, which fix the 15 digit length. The page sits behind a bot filter + * and answers HTTP 403 to every non-browser client, so it has to be opened in a browser. + * @see Based on: https://integracao.esusab.ufsc.br/ledi/documentacao/regras/algoritmo_CNS.html + * e-SUS APS documentation of the same DATASUS algorithm, reachable without a browser. + */ +export const parseCns = (value: string | number): string => + isNullish(value) ? "" : sanitizeToDigits(value).slice(0, LENGTH); diff --git a/src/parse-cpf/parse-cpf.ts b/src/parse-cpf/parse-cpf.ts index 00d8079d..4955026f 100644 --- a/src/parse-cpf/parse-cpf.ts +++ b/src/parse-cpf/parse-cpf.ts @@ -14,7 +14,6 @@ import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-d * ``` * * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/meu-cpf - * @see Official: http://sped.rfb.gov.br/arquivo/show/8231 * @see Based on: https://github.com/brazilian-utils/python/blob/main/brutils/cpf.py */ export const parseCpf = (value: string | number): string => diff --git a/src/parse-currency/parse-currency.test.ts b/src/parse-currency/parse-currency.test.ts index 7b0bbf42..b39b2df4 100644 --- a/src/parse-currency/parse-currency.test.ts +++ b/src/parse-currency/parse-currency.test.ts @@ -110,6 +110,21 @@ describe("parseCurrency", () => { expect(parseCurrency("R$ 150", { precision: -1 })).toBe(150); expect(parseCurrency("R$ 150", { precision: 21 })).toBe(150 / 10 ** 20); }); + + test("when the precision is not a finite number", () => { + // @ts-expect-error: intentionally invalid input + expect(parseCurrency("1,001", { precision: "3" })).toBe(1001); + // @ts-expect-error: intentionally invalid input + expect(parseCurrency("R$ 1,50", { precision: null })).toBe(1.5); + }); + }); + + describe("should return 0 for a value with no numeric reading", () => { + test("when the value is a null-prototype object or a symbol", () => { + expect(parseCurrency(Object.create(null))).toBe(0); + // @ts-expect-error: intentionally invalid input + expect(parseCurrency(Symbol("x"))).toBe(0); + }); }); describe("should round-trip with formatCurrency", () => { @@ -127,9 +142,16 @@ describe("parseCurrency", () => { }); describe("properties", () => { + const hostileValues = fc.oneof( + fc.anything(), + fc.constant(Object.create(null)), + fc.constant(Symbol("x")), + fc.bigInt(), + ); + test("should never throw and always return a finite number, regardless of the input", () => { fc.assert( - fc.property(fc.anything(), (value) => { + fc.property(hostileValues, (value) => { const result = parseCurrency(value as never); expect(Number.isFinite(result)).toBe(true); diff --git a/src/parse-currency/parse-currency.ts b/src/parse-currency/parse-currency.ts index b115adac..25889303 100644 --- a/src/parse-currency/parse-currency.ts +++ b/src/parse-currency/parse-currency.ts @@ -17,11 +17,20 @@ export type ParseCurrencyOptions = { * parses to 12.34. A `-` written before the first digit is preserved, so `"-R$ 1,00"` parses * to -1. * + * The precision is clamped to `0-20`, and a precision that is not a finite number falls back + * to 2. + * * @param {string} value - The string value to be parsed (e.g., "R$ 1.234,56" or "1234,56") * @param {ParseCurrencyOptions} [options] - Optional parsing options. * @param {number} options.precision - The number of decimal places used as the minor unit scale. Fractions accept up to two digits, or `precision` digits when it is greater. Defaults to 2, clamped to 0-20. * @returns {number} The parsed number value (e.g., 1234.56) * + * The `R$` prefix and the comma before the centavos are the ones Lei nº 9.069/1995, art. 1º, + * §§ 1º and 2º prescribes; the `.` grouping comes from the CLDR pt-BR locale data. + * + * @see Official: https://www.planalto.gov.br/ccivil_03/leis/l9069.htm + * @see Based on: https://cldr.unicode.org/ + * * @example * ```typescript * parseCurrency("R$ 1.234,56"); // returns 1234.56 diff --git a/src/parse-iban/parse-iban.test.ts b/src/parse-iban/parse-iban.test.ts index 9c7e554a..add0fcf9 100644 --- a/src/parse-iban/parse-iban.test.ts +++ b/src/parse-iban/parse-iban.test.ts @@ -1,220 +1,68 @@ import * as fc from "fast-check"; -import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; +import { BR_IBAN_LENGTH } from "../_internals/constants/iban"; +import { anyText, anyValue, asciiAlphanumericText } from "../_internals/test/arbitraries"; +import { + expectAlwaysReturnsType, + expectCaseInsensitive, + expectIdempotent, + expectMatchesPattern, + expectRoundTrip, +} from "../_internals/test/properties"; +import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; import { formatIban } from "../format-iban/format-iban"; -import { isValidIban } from "../is-valid-iban/is-valid-iban"; -import { parseIban, type Iban } from "./parse-iban"; - -const findBrazilianIban = (body: string): string => { - for (let pair = 2; pair <= 98; pair++) { - const candidate = `BR${String(pair).padStart(2, "0")}${body}`; - - if (isValidIban(candidate)) return candidate; - } - - return ""; -}; +import { parseIban } from "./parse-iban"; describe("parseIban", () => { - describe("should return the parsed iban", () => { - test("for a known valid IBAN (iban.com Brazil example)", () => { - expect(parseIban("BR1500000000000010932840814P2")).toEqual({ - countryCode: "BR", - checkDigits: "15", - bankIspb: "00000000", - branch: "00001", - account: "0932840814", - accountType: "P", - owner: "2", - }); - }); - - test("for a value with grouping spaces", () => { - expect(parseIban("BR15 0000 0000 0000 1093 2840 814P 2")).toEqual({ - countryCode: "BR", - checkDigits: "15", - bankIspb: "00000000", - branch: "00001", - account: "0932840814", - accountType: "P", - owner: "2", - }); - }); - - test("for a lowercase value", () => { - expect(parseIban("br1500000000000010932840814p2")).toEqual({ - countryCode: "BR", - checkDigits: "15", - bankIspb: "00000000", - branch: "00001", - account: "0932840814", - accountType: "P", - owner: "2", - }); - }); - - test("for a valid IBAN with a corrente (C) account type", () => { - expect(parseIban("BR3860701190000010000012345C1")).toEqual({ - countryCode: "BR", - checkDigits: "38", - bankIspb: "60701190", - branch: "00001", - account: "0000012345", - accountType: "C", - owner: "1", - }); - }); - - test("for a valid IBAN with a poupança (P) account type and a non zero branch", () => { - expect(parseIban("BR1460746948000020001234567P2")).toEqual({ - countryCode: "BR", - checkDigits: "14", - bankIspb: "60746948", - branch: "00002", - account: "0001234567", - accountType: "P", - owner: "2", - }); - }); - - test("for a valid IBAN with an account type letter other than C or P", () => { - expect(parseIban("BR5400000000000010932840814D2")).toEqual({ - countryCode: "BR", - checkDigits: "54", - bankIspb: "00000000", - branch: "00001", - account: "0932840814", - accountType: "D", - owner: "2", - }); - }); + it("should remove the ISO 13616 print grouping", () => { + expect(parseIban("BR15 0000 0000 0000 1093 2840 814P 2")).toBe("BR1500000000000010932840814P2"); }); - describe("should return null", () => { - test("when the check digits do not match", () => { - expect(parseIban("BR1500000000000010932840814P3")).toBeNull(); - }); - - test("when the country code is not BR", () => { - expect(parseIban("DE89370400440532013000")).toBeNull(); - }); - - test("when it is shorter than 29 characters", () => { - expect(parseIban("BR15000000000000109328408")).toBeNull(); - }); - - test("when it is longer than 29 characters", () => { - expect(parseIban("BR1500000000000010932840814P2000")).toBeNull(); - }); - - test("when the account type is not a letter", () => { - expect(parseIban("BR150000000000001093284081412")).toBeNull(); - }); - - test("when the owner indicator is 0, which Circular 3.625 art. 2 § 1 does not assign, even though the check digits match", () => { - expect(parseIban("BR6900000000000010932840814P0")).toBeNull(); - }); - - test("when the account type letter does not match the check digits", () => { - expect(parseIban("BR1500000000000010932840814X2")).toBeNull(); - }); - - test("when it carries a character outside the print format", () => { - expect(parseIban("BR1500000000000010932840814P-2")).toBeNull(); - expect(parseIban("BR15.0000.0000.0000.1093.2840.814P2")).toBeNull(); - }); - - test("when it is an empty string", () => { - expect(parseIban("")).toBeNull(); - }); - - test("when it is null", () => { - // @ts-expect-error: intentionally invalid input - expect(parseIban(null)).toBeNull(); - }); - - test("when it is undefined", () => { - // @ts-expect-error: intentionally invalid input - expect(parseIban()).toBeNull(); - }); - - test("when it is a number", () => { - // @ts-expect-error: intentionally invalid input - expect(parseIban(150_000_000_000)).toBeNull(); - }); + it("should uppercase the letters and drop every other character", () => { + expect(parseIban("br15-0000.0000/0000 1093 2840 814p-2")).toBe("BR1500000000000010932840814P2"); }); - describe("should round-trip with formatIban and isValidIban", () => { - const IBANS = [ - "BR1500000000000010932840814P2", - "BR3860701190000010000012345C1", - "BR1460746948000020001234567P2", - "BR5400000000000010932840814D2", - ]; - - for (const iban of IBANS) { - test(`for ${iban}`, () => { - expect(isValidIban(iban)).toBe(true); + it(`should ignore characters after the Brazilian IBAN length (${BR_IBAN_LENGTH})`, () => { + expect(parseIban("BR1500000000000010932840814P2EXTRA")).toBe("BR1500000000000010932840814P2"); + }); - const parsed = parseIban(iban); + it("should keep a partial IBAN as written", () => { + expect(parseIban("BR15")).toBe("BR15"); + }); - expect(parsed).not.toBeNull(); - expect( - `BR${parsed?.checkDigits}${parsed?.bankIspb}${parsed?.branch}${parsed?.account}${parsed?.accountType}${parsed?.owner}`, - ).toBe(iban); - expect(formatIban(iban)).toBe(formatIban(iban.toUpperCase())); - }); - } + it("should return an empty string for null", () => { + // @ts-expect-error not a string or number + expect(parseIban(null)).toBe(""); }); describe("properties", () => { - const bodies = fc.stringMatching(/^[0-9]{23}[A-Z][A-Z1-9]$/); + const upToAnIban = fc.stringMatching(/^[0-9A-Z]{0,29}$/); - test("should split an IBAN into fields that spell it back", () => { - fc.assert( - fc.property(bodies, (body) => { - const iban = findBrazilianIban(body); - const parsed = parseIban(formatIban(iban)); - const account = `${parsed?.bankIspb}${parsed?.branch}${parsed?.account}`; - const owner = `${parsed?.accountType}${parsed?.owner}`; + test("should return at most the characters of a Brazilian IBAN", () => { + expectMatchesPattern(parseIban, /^[0-9A-Z]{0,29}$/, anyText); + }); - expect(`${parsed?.countryCode}${parsed?.checkDigits}${account}${owner}`).toBe(iban); - }), - ); + test("should undo formatIban", () => { + expectRoundTrip(formatIban, parseIban, upToAnIban); }); - test("should return a value exactly when the IBAN is valid", () => { - fc.assert( - fc.property(fc.string({ unit: "grapheme" }), (value) => { - expect(parseIban(value) !== null).toBe(isValidIban(value)); - }), - ); + test("should be idempotent", () => { + expectIdempotent(parseIban, anyText); }); - test("should never throw and always return an IBAN or null", () => { - fc.assert( - fc.property(fc.anything(), (value) => { - const parsed = parseIban(value as string); + test("should ignore the case of an ascii alphanumeric value", () => { + expectCaseInsensitive(parseIban, asciiAlphanumericText); + }); - expect(parsed === null || parsed.countryCode === "BR").toBe(true); - }), - ); + test("should never throw and always return a string", () => { + expectAlwaysReturnsType(parseIban, "string", anyValue); }); }); }); describe("parseIban types", () => { - test("should take a string and return an Iban or null", () => { - expectTypeOf(parseIban).parameter(0).toEqualTypeOf(); - expectTypeOf(parseIban).returns.toEqualTypeOf(); - expectTypeOf().toEqualTypeOf<{ - countryCode: "BR"; - checkDigits: string; - bankIspb: string; - branch: string; - account: string; - accountType: string; - owner: string; - }>(); + test("should take a string or number value and return a string", () => { + expectTypeOf(parseIban).parameter(0).toEqualTypeOf(); + expectTypeOf(parseIban).returns.toEqualTypeOf(); }); }); diff --git a/src/parse-iban/parse-iban.ts b/src/parse-iban/parse-iban.ts index a6646406..3043b023 100644 --- a/src/parse-iban/parse-iban.ts +++ b/src/parse-iban/parse-iban.ts @@ -1,99 +1,30 @@ +import { BR_IBAN_LENGTH } from "../_internals/constants/iban"; +import { isNullish } from "../_internals/is-nullish/is-nullish"; import { sanitizeToAlphanumeric } from "../_internals/sanitize-to-alphanumeric/sanitize-to-alphanumeric"; -import { isValidIban } from "../is-valid-iban/is-valid-iban"; - -/** The fields `parseIban` reads out of a Brazilian IBAN. */ -export type Iban = { - /** ISO 3166-1 alpha-2 country code. Always `"BR"`, the only country this parser supports. */ - countryCode: "BR"; - /** The 2 digit ISO 7064 MOD 97-10 check digits. */ - checkDigits: string; - /** The 8 digit ISPB (Identificador do Sistema de Pagamentos Brasileiro) of the institution. */ - bankIspb: string; - /** The 5 digit branch (agência) number, zero-padded. */ - branch: string; - /** The 10 digit account (conta) number, zero-padded. */ - account: string; - /** - * The 1 letter account type, as published in the "Dicionário de Tipos" of the Catálogo de - * Mensagens e de Arquivos do SFN. `"C"` (conta corrente) and `"P"` (conta poupança) are the - * usual values, but any letter is allowed. - */ - accountType: string; - /** - * The 1 character owner indicator, distinguishing co-owners of the same account: `"1"` for - * the first or only holder up to `"9"` for the ninth, then `"A"` to `"Z"` from the tenth. - */ - owner: string; -}; - -const COUNTRY_CODE_LENGTH = 2; -const CHECK_DIGITS_LENGTH = 2; -const ISPB_LENGTH = 8; -const BRANCH_LENGTH = 5; -const ACCOUNT_LENGTH = 10; -const ACCOUNT_TYPE_LENGTH = 1; - -const COUNTRY_CODE_END = COUNTRY_CODE_LENGTH; -const CHECK_DIGITS_END = COUNTRY_CODE_END + CHECK_DIGITS_LENGTH; -const ISPB_END = CHECK_DIGITS_END + ISPB_LENGTH; -const BRANCH_END = ISPB_END + BRANCH_LENGTH; -const ACCOUNT_END = BRANCH_END + ACCOUNT_LENGTH; -const ACCOUNT_TYPE_END = ACCOUNT_END + ACCOUNT_TYPE_LENGTH; /** - * Parses a Brazilian IBAN (International Bank Account Number) into its fields. - * - * The 29 character Brazilian IBAN is laid out as 2 (country code, always `BR`) + 2 (ISO 7064 - * MOD 97-10 check digits) + 8 (ISPB) + 5 (branch) + 10 (account) + 1 (account type, any letter, - * usually `C` for conta corrente or `P` for conta poupança) + 1 (owner indicator, `1` to `9` - * then `A` to `Z`). Only - * Brazilian IBANs are supported: the field layout of the other ISO 13616 countries is out of - * scope, so a well-formed non `BR` IBAN also returns `null`. + * Removes IBAN formatting characters, uppercases the result and returns the compact IBAN. * - * Accepts the same input forms as `isValidIban`, compact or in the ISO 13616 print format - * (groups separated by a single space), in either case with optional surrounding whitespace and - * in any case, and returns `null` whenever `isValidIban` would return `false`, including a value - * carrying any character other than letters, digits and those single grouping spaces. + * An IBAN carries letters as well as digits (the country code, the account type and, from the + * tenth holder on, the owner indicator), so the value is read for its letters and digits rather + * than for its digits alone, exactly like `parsePassport` and `formatIban` do. The result is + * capped at the 29 characters of a Brazilian IBAN, the same cap `formatIban` applies, and a + * shorter value passes through as far as it goes. Use `isValidIban` to check the check digits and + * `getIbanInfo` to read the fields. * - * @param {string} value - The IBAN to be parsed. - * @returns {Iban|null} The parsed IBAN, or `null` when it is not a valid Brazilian IBAN. + * @param {string|number} value - The IBAN to be parsed. + * @returns {string} Up to 29 uppercase alphanumeric characters, or an empty string when there is + * no letter or digit at all. * * @example * ```typescript - * parseIban("BR1500000000000010932840814P2"); - * // { - * // countryCode: "BR", - * // checkDigits: "15", - * // bankIspb: "00000000", - * // branch: "00001", - * // account: "0932840814", - * // accountType: "P", - * // owner: "2", - * // } - * - * parseIban("BR15 0000 0000 0000 1093 2840 814P 2"); // same result (grouping spaces) - * parseIban("DE89370400440532013000"); // null (non Brazilian IBAN) - * parseIban("BR1500000000000010932840814P3"); // null (bad check digits) - * parseIban("BR1500000000000010932840814P-2"); // null (hyphens are not part of an IBAN) + * parseIban("BR15 0000 0000 0000 1093 2840 814P 2"); // "BR1500000000000010932840814P2" * ``` * - * @see Official: https://www.bcb.gov.br/pre/normativos/circ/2013/pdf/circ_3625_v1_O.pdf Circular BCB nº 3.625/2013 - * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/Documents/sistema_pagamentos_brasileiro/IBAN-Guidelines_%20port.pdf Diretrizes de Implementação do IBAN no Brasil - * @see Official: https://www.iso.org/standard/81090.html ISO 13616-1:2020 (IBAN structure) - * @see Official: https://www.iso.org/standard/31531.html ISO/IEC 7064:2003 (MOD 97-10 check digit algorithm) + * @see Official: https://www.bcb.gov.br/pre/normativos/circ/2013/pdf/circ_3625_v1_O.pdf + * Circular BCB nº 3.625/2013 + * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/Documents/sistema_pagamentos_brasileiro/IBAN-Guidelines_%20port.pdf + * Diretrizes de Implementação do IBAN no Brasil, which fix the 29 character Brazilian length. */ -export const parseIban = (value: string): Iban | null => { - if (!isValidIban(value)) return null; - - const sanitized = sanitizeToAlphanumeric(value); - - return { - countryCode: "BR", - checkDigits: sanitized.slice(COUNTRY_CODE_END, CHECK_DIGITS_END), - bankIspb: sanitized.slice(CHECK_DIGITS_END, ISPB_END), - branch: sanitized.slice(ISPB_END, BRANCH_END), - account: sanitized.slice(BRANCH_END, ACCOUNT_END), - accountType: sanitized.charAt(ACCOUNT_END), - owner: sanitized.slice(ACCOUNT_TYPE_END), - }; -}; +export const parseIban = (value: string | number): string => + isNullish(value) ? "" : sanitizeToAlphanumeric(value).slice(0, BR_IBAN_LENGTH); diff --git a/src/parse-legal-nature/parse-legal-nature.ts b/src/parse-legal-nature/parse-legal-nature.ts index d1745002..e76df355 100644 --- a/src/parse-legal-nature/parse-legal-nature.ts +++ b/src/parse-legal-nature/parse-legal-nature.ts @@ -13,6 +13,10 @@ import { LENGTH } from "./constants"; * parseLegalNature("206-2"); // "2062" * ``` * + * The CONCLA table page sits behind a bot filter and answers HTTP 403 to every non-browser + * client, so it has to be opened in a browser; the detailed structure PDF next to it is served + * normally. + * * @see Official: https://concla.ibge.gov.br/estrutura/natjur-estrutura/natureza-juridica-2021 * @see Official: https://concla.ibge.gov.br/images/concla/documentacao/CONCLA-TNJ2021-EstruturaDetalhada.pdf */ diff --git a/src/parse-ncm/constants.ts b/src/parse-ncm/constants.ts new file mode 100644 index 00000000..aab8be37 --- /dev/null +++ b/src/parse-ncm/constants.ts @@ -0,0 +1,2 @@ +/** Digits of a complete NCM code, printed as "0000.00.00". */ +export const LENGTH = 8; diff --git a/src/parse-ncm/parse-ncm.test.ts b/src/parse-ncm/parse-ncm.test.ts new file mode 100644 index 00000000..83e701d8 --- /dev/null +++ b/src/parse-ncm/parse-ncm.test.ts @@ -0,0 +1,62 @@ +import { anyText, anyValue, digitsUpTo } from "../_internals/test/arbitraries"; +import { + expectAlwaysReturnsType, + expectIdempotent, + expectMatchesPattern, + expectRoundTrip, +} from "../_internals/test/properties"; +import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; +import { formatNcm } from "../format-ncm/format-ncm"; +import { parseNcm } from "./parse-ncm"; + +describe("parseNcm", () => { + it("should remove NCM mask characters", () => { + expect(parseNcm("8471.30.12")).toBe("84713012"); + }); + + it("should remove non numeric characters", () => { + expect(parseNcm("84?ABC71.30.12abc")).toBe("84713012"); + }); + + it("should ignore digits after the NCM length", () => { + expect(parseNcm("84713012999")).toBe("84713012"); + }); + + it("should read a number as the string of its digits", () => { + expect(parseNcm(84_713_012)).toBe("84713012"); + }); + + it("should keep a partial code as written, without padding it", () => { + expect(parseNcm("8471")).toBe("8471"); + }); + + it("should return an empty string for null", () => { + // @ts-expect-error not a string or number + expect(parseNcm(null)).toBe(""); + }); + + describe("properties", () => { + test("should return at most the digits of an NCM code", () => { + expectMatchesPattern(parseNcm, /^\d{0,8}$/, anyText); + }); + + test("should undo formatNcm", () => { + expectRoundTrip(formatNcm, parseNcm, digitsUpTo(8)); + }); + + test("should be idempotent", () => { + expectIdempotent(parseNcm, anyText); + }); + + test("should never throw and always return a string", () => { + expectAlwaysReturnsType(parseNcm, "string", anyValue); + }); + }); +}); + +describe("parseNcm types", () => { + test("should take a string or number value and return a string", () => { + expectTypeOf(parseNcm).parameter(0).toEqualTypeOf(); + expectTypeOf(parseNcm).returns.toEqualTypeOf(); + }); +}); diff --git a/src/parse-ncm/parse-ncm.ts b/src/parse-ncm/parse-ncm.ts new file mode 100644 index 00000000..fa25d92b --- /dev/null +++ b/src/parse-ncm/parse-ncm.ts @@ -0,0 +1,24 @@ +import { isNullish } from "../_internals/is-nullish/is-nullish"; +import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { LENGTH } from "./constants"; + +/** + * Removes NCM (Nomenclatura Comum do Mercosul) formatting characters and returns only digits. + * + * A complete code has 8 digits, which is the length the result is capped at; a shorter value (a + * position or a subposition, or a code still being typed) passes through as far as it goes and is + * never left padded, so the leading zeros a code carries have to be written out. Use `isValidNcm`, + * which does pad a bare numeric code, to check a code against the official table. + * + * @param {string|number} value - The NCM code to be parsed. + * @returns {string} Up to 8 digits, or an empty string when there is no digit at all. + * + * @example + * ```typescript + * parseNcm("8471.30.12"); // "84713012" + * ``` + * + * @see Official: https://portalunico.siscomex.gov.br/classif/api/publico/nomenclatura/download/json + */ +export const parseNcm = (value: string | number): string => + isNullish(value) ? "" : sanitizeToDigits(value).slice(0, LENGTH); diff --git a/src/parse-nfe-key/parse-nfe-key.test.ts b/src/parse-nfe-key/parse-nfe-key.test.ts index bad695aa..815fe45f 100644 --- a/src/parse-nfe-key/parse-nfe-key.test.ts +++ b/src/parse-nfe-key/parse-nfe-key.test.ts @@ -1,278 +1,77 @@ -import * as fc from "fast-check"; - -import { IBGE_UF_CODES } from "../_internals/constants/ibge-uf-codes"; -import { type StateCode } from "../_internals/constants/states"; -import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; -import { EMISSION_TYPES_BY_MODEL, FORBIDDEN_CODES, VALID_MODELS } from "./constants"; -import { parseNfeKey, type NfeKey, type NfeKeyModel } from "./parse-nfe-key"; - -const KEY_SP = "35170458716523000119550010000000121000123458"; -const KEY_RS = "43160472202112000136550000000010571048440722"; -const KEY_CPF_PADDED = "35170400040364478829550010000000121000123457"; - -const CHECK_DIGITS = Array.from({ length: 10 }, (_, digit) => String(digit)); - -const AUTHORIZATION_SITE_MODELS = new Set(["62", "66"]); - -const MODEL_EMISSION_TYPES: { model: string; emissionType: number }[] = VALID_MODELS.flatMap( - (model) => EMISSION_TYPES_BY_MODEL[model].map((emissionType) => ({ model, emissionType })), -); - -const buildNfeKey = (base: string): string => - CHECK_DIGITS.map((digit) => `${base}${digit}`).find((key) => parseNfeKey(key) !== null) ?? ""; +import { NFE_KEY_LENGTH } from "../_internals/constants/nfe-key"; +import { anyText, anyValue, digitsUpTo } from "../_internals/test/arbitraries"; +import { + expectAlwaysReturnsType, + expectIdempotent, + expectMatchesPattern, + expectRoundTrip, +} from "../_internals/test/properties"; +import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; +import { formatNfeKey } from "../format-nfe-key/format-nfe-key"; +import { parseNfeKey } from "./parse-nfe-key"; + +const KEY = "35170458716523000119550010000000121000123458"; describe("parseNfeKey", () => { - describe("should return null", () => { - test("when it is null", () => { - // @ts-expect-error: intentionally invalid input - expect(parseNfeKey(null)).toBeNull(); - }); - - test("when it is undefined", () => { - // @ts-expect-error: intentionally invalid input - expect(parseNfeKey()).toBeNull(); - }); - - test("when it is a number", () => { - // @ts-expect-error: intentionally invalid input - expect(parseNfeKey(123)).toBeNull(); - }); - - test("when it is an empty string", () => { - expect(parseNfeKey("")).toBeNull(); - }); - - test("when the check digit does not match", () => { - expect(parseNfeKey(`${KEY_SP.slice(0, 43)}9`)).toBeNull(); - }); - - test("when the model is not one of the nine supported (model 99 with a matching check digit)", () => { - expect(parseNfeKey("35170458716523000119990010000000121000123453")).toBeNull(); - }); - - test("when the document number is zero", () => { - expect(parseNfeKey("35170458716523000119550010000000001000123457")).toBeNull(); - }); - - test("when tpEmis is 8, which the NF-e MOC does not assign, even with a matching check digit", () => { - expect(parseNfeKey("35170458716523000119550010000000128000123455")).toBeNull(); - }); - - test("when tpEmis belongs to another model: 2 for a CT-e, 3 for a CT-e OS, 9 for an MDF-e, 3 for a BP-e", () => { - expect(parseNfeKey("35170458716523000119570010000000122000123453")).toBeNull(); - expect(parseNfeKey("35170458716523000119670010000000123000123454")).toBeNull(); - expect(parseNfeKey("35170458716523000119580010000000129000123454")).toBeNull(); - expect(parseNfeKey("35170458716523000119630010000000123000123450")).toBeNull(); - }); - - test("when the cNF of an NF-e is one rule B03-10 of the MOC forbids", () => { - expect(parseNfeKey("35170458716523000119550010000000121000000003")).toBeNull(); - expect(parseNfeKey("35170458716523000119550010000000121111111113")).toBeNull(); - expect(parseNfeKey("35170458716523000119550010000000121123456781")).toBeNull(); - }); - - test("when the cNF of an NF-e equals its nNF, the second half of rule B03-10", () => { - expect(parseNfeKey("35170458716523000119550010000123451000123458")).toBeNull(); - }); - - test("when the access key is otherwise invalid", () => { - expect(parseNfeKey("not-a-key")).toBeNull(); - }); + it("should remove the printed grouping of an access key", () => { + expect(parseNfeKey("3517 0458 7165 2300 0119 5500 1000 0000 1210 0012 3458")).toBe(KEY); }); - describe("should return the parsed access key", () => { - test("for a NF-e access key (SP), the NFePHP `Keys::build` doc example also used in is-valid-nfe-key.test.ts", () => { - expect(parseNfeKey(KEY_SP)).toEqual({ - state: "SP", - year: 2017, - month: 4, - taxId: "58716523000119", - model: "55", - series: 1, - number: 12, - emissionType: 1, - code: "00012345", - checkDigit: 8, - }); - }); - - test("for a NF-e access key (RS), the NFePHP sped-cte `$infNFe->chave` example (NF-e referenced by a CT-e)", () => { - expect(parseNfeKey(KEY_RS)).toEqual({ - state: "RS", - year: 2016, - month: 4, - taxId: "72202112000136", - model: "55", - series: 0, - number: 1057, - emissionType: 1, - code: "04844072", - checkDigit: 2, - }); - }); - - test("accepting the NFe XML prefix and a whitespace mask", () => { - expect(parseNfeKey(`NFe${KEY_SP}`)?.taxId).toBe("58716523000119"); - expect(parseNfeKey("3517 0458 7165 2300 0119 5500 1000 0000 1210 0012 3458")?.number).toBe( - 12, - ); - }); - - test("accepting the XML Id prefix of every other covered document", () => { - expect(parseNfeKey("CTe35170458716523000119570010000000121000123455")?.model).toBe("57"); - expect(parseNfeKey("MDFe35170458716523000119580010000000121000123459")?.model).toBe("58"); - expect(parseNfeKey("BPe35170458716523000119630010000000121000123453")?.model).toBe("63"); - expect(parseNfeKey("NF3e35170458716523000119660010000000121000123454")?.model).toBe("66"); - expect(parseNfeKey("NFCom35170458716523000119620010000000121000123450")?.model).toBe("62"); - }); - - test("for the CT-e models the SVC-SP authorises, whose MOC assigns tpEmis 8", () => { - expect(parseNfeKey("35170458716523000119570010000000128000123452")?.emissionType).toBe(8); - expect(parseNfeKey("35170458716523000119670010000000128000123455")?.emissionType).toBe(8); - expect(parseNfeKey("35170458716523000119640010000000128000123454")?.emissionType).toBe(8); - }); - - test("for the MDF-e contingência Regime Especial NFF, tpEmis 3", () => { - expect(parseNfeKey("35170458716523000119580010000000123000123455")?.emissionType).toBe(3); - }); - - test("keeping the cNF of a CT-e that rule B03-10 would forbid, since only the NF-e MOC states it", () => { - expect(parseNfeKey("35170458716523000119570010000000121000000000")?.code).toBe("00000000"); - expect(parseNfeKey("35170458716523000119570010000123451000123455")?.code).toBe("00012345"); - }); + it("should remove non numeric characters", () => { + expect(parseNfeKey("3517.0458.7165.2300.0119.5500.1000.0000.1210.0012.3458")).toBe(KEY); + }); - test("keeping the left zero padding of a CPF issuer, using a synthetic key with an 11-digit CPF left-padded to 14 digits in the tax id field and the check digit recalculated", () => { - expect(parseNfeKey(KEY_CPF_PADDED)?.taxId).toBe("00040364478829"); - expect(parseNfeKey(KEY_CPF_PADDED)?.taxId).toHaveLength(14); - }); + it("should strip the XML Id prefix of every document", () => { + expect(parseNfeKey(`NFe${KEY}`)).toBe(KEY); + expect(parseNfeKey(`CTe${KEY}`)).toBe(KEY); + expect(parseNfeKey(`MDFe${KEY}`)).toBe(KEY); + expect(parseNfeKey(`BPe${KEY}`)).toBe(KEY); + expect(parseNfeKey(`NFCom${KEY}`)).toBe(KEY); + }); - test("for tpEmis 9, the off-line NFC-e contingency, same shape as the SP key with the tpEmis field changed and the check digit recalculated", () => { - expect(parseNfeKey("35170458716523000119550010000000129000123453")?.emissionType).toBe(9); - }); + it("should strip the NF3e prefix instead of reading its digit as part of the key", () => { + expect(parseNfeKey(`NF3e${KEY}`)).toBe(KEY); + }); - test("for every other DF-e model (CT-e, MDF-e, GTV-e, NFC-e, CT-e OS), same shape as the SP key with the model field changed and the check digit recalculated", () => { - expect(parseNfeKey("35170458716523000119570010000000121000123455")?.model).toBe("57"); - expect(parseNfeKey("35170458716523000119580010000000121000123459")?.model).toBe("58"); - expect(parseNfeKey("35170458716523000119630010000000121000123453")?.model).toBe("63"); - expect(parseNfeKey("35170458716523000119640010000000121000123457")?.model).toBe("64"); - expect(parseNfeKey("35170458716523000119650010000000121000123450")?.model).toBe("65"); - expect(parseNfeKey("35170458716523000119670010000000121000123458")?.model).toBe("67"); - }); + it("should strip the XML Id prefix behind leading whitespace", () => { + expect(parseNfeKey(` NF3e${KEY}`)).toBe(KEY); + }); - test("splitting nSiteAutoriz from the 7 digit cNF of an NFCom, per its Visão Geral §2.1.3", () => { - expect(parseNfeKey("35170458716523000119620010000000121000123450")).toEqual({ - state: "SP", - year: 2017, - month: 4, - taxId: "58716523000119", - model: "62", - series: 1, - number: 12, - emissionType: 1, - authorizationSite: 0, - code: "0012345", - checkDigit: 0, - }); - expect(parseNfeKey("35170458716523000119620010000000121700123452")?.authorizationSite).toBe( - 7, - ); - }); + it(`should ignore digits after the access key length (${NFE_KEY_LENGTH})`, () => { + expect(parseNfeKey(`${KEY}999`)).toBe(KEY); + }); - test("splitting nSiteAutoriz from the 7 digit cNF of an NF3e, per its Visão Geral", () => { - expect(parseNfeKey("35170458716523000119660010000000121000123454")).toEqual({ - state: "SP", - year: 2017, - month: 4, - taxId: "58716523000119", - model: "66", - series: 1, - number: 12, - emissionType: 1, - authorizationSite: 0, - code: "0012345", - checkDigit: 4, - }); - }); + it("should keep a partial access key as written", () => { + expect(parseNfeKey("3517 0458")).toBe("35170458"); + }); - test("without an authorizationSite property for a model whose key has no nSiteAutoriz", () => { - expect(parseNfeKey(KEY_SP)).not.toHaveProperty("authorizationSite"); - }); + it("should return an empty string for null", () => { + // @ts-expect-error not a string or number + expect(parseNfeKey(null)).toBe(""); }); describe("properties", () => { - const parts = fc.tuple( - fc.constantFrom(...Object.keys(IBGE_UF_CODES)), - fc.stringMatching(/^[0-9]{2}$/), - fc.integer({ min: 1, max: 12 }), - fc.stringMatching(/^[0-9]{14}$/), - fc.constantFrom(...MODEL_EMISSION_TYPES), - fc.stringMatching(/^[0-9]{3}$/), - fc.integer({ min: 1, max: 999_999_999 }), - fc.stringMatching(/^[0-9]{8}$/), - ); - - test("should give back every field of a well-formed access key", () => { - fc.assert( - fc.property(parts, (fields) => { - const [uf, year, month, taxId, document, series, number, tail] = fields; - const { model, emissionType } = document; - const hasSite = AUTHORIZATION_SITE_MODELS.has(model); - const code = hasSite ? tail.slice(1) : tail; - - fc.pre(!FORBIDDEN_CODES.includes(code) && Number(code) !== number); - - const issuer = `${uf}${year}${String(month).padStart(2, "0")}${taxId}`; - const numbering = `${model}${series}${String(number).padStart(9, "0")}`; - const key = buildNfeKey(`${issuer}${numbering}${emissionType}${tail}`); - const parsed = parseNfeKey(key); + test("should return at most the digits of an access key", () => { + expectMatchesPattern(parseNfeKey, /^\d{0,44}$/, anyText); + }); - expect(parsed?.state).toBe(IBGE_UF_CODES[uf]); - expect(parsed?.year).toBe(2000 + Number(year)); - expect(parsed?.month).toBe(month); - expect(parsed?.taxId).toBe(taxId); - expect(parsed?.model).toBe(model); - expect(parsed?.series).toBe(Number(series)); - expect(parsed?.number).toBe(number); - expect(parsed?.emissionType).toBe(emissionType); - expect(parsed?.authorizationSite).toBe(hasSite ? Number(tail.charAt(0)) : undefined); - expect(parsed?.code).toBe(code); - expect(parsed?.checkDigit).toBe(Number(key.charAt(43))); - }), - ); + test("should undo formatNfeKey", () => { + expectRoundTrip(formatNfeKey, parseNfeKey, digitsUpTo(NFE_KEY_LENGTH)); }); - test("should never throw and always return an access key or null", () => { - fc.assert( - fc.property(fc.anything(), (value) => { - const parsed = parseNfeKey(value as string); + test("should be idempotent", () => { + expectIdempotent(parseNfeKey, anyText); + }); - expect(parsed === null || typeof parsed.taxId === "string").toBe(true); - }), - ); + test("should never throw and always return a string", () => { + expectAlwaysReturnsType(parseNfeKey, "string", anyValue); }); }); }); describe("parseNfeKey types", () => { - test("should take a string and return an NfeKey or null", () => { - expectTypeOf(parseNfeKey).parameter(0).toEqualTypeOf(); - expectTypeOf(parseNfeKey).returns.toEqualTypeOf(); - expectTypeOf().toEqualTypeOf<{ - state: StateCode; - year: number; - month: number; - taxId: string; - model: NfeKeyModel; - series: number; - number: number; - emissionType: number; - authorizationSite?: number; - code: string; - checkDigit: number; - }>(); - expectTypeOf().toEqualTypeOf< - "55" | "57" | "58" | "62" | "63" | "64" | "65" | "66" | "67" - >(); - expectTypeOf().toEqualTypeOf<(typeof VALID_MODELS)[number]>(); + test("should take a string or number value and return a string", () => { + expectTypeOf(parseNfeKey).parameter(0).toEqualTypeOf(); + expectTypeOf(parseNfeKey).returns.toEqualTypeOf(); }); }); diff --git a/src/parse-nfe-key/parse-nfe-key.ts b/src/parse-nfe-key/parse-nfe-key.ts index 059a17fe..32576e5a 100644 --- a/src/parse-nfe-key/parse-nfe-key.ts +++ b/src/parse-nfe-key/parse-nfe-key.ts @@ -1,189 +1,50 @@ -import { IBGE_UF_CODES } from "../_internals/constants/ibge-uf-codes"; -import { NFE_KEY_LENGTH } from "../_internals/constants/nfe-key"; -import { type StateCode } from "../_internals/constants/states"; -import { mod11 } from "../_internals/mod11/mod11"; +import { NFE_KEY_LENGTH, XML_ID_PREFIX_REGEX } from "../_internals/constants/nfe-key"; +import { isNullish } from "../_internals/is-nullish/is-nullish"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; -import { - ABSENT_NUMBER, - AUTHORIZATION_SITE_MODELS, - EMISSION_TYPES_BY_MODEL, - FORBIDDEN_CODES, - FORBIDDEN_CODE_MODELS, - FORMAT_REGEX, - NUMBER_END, - NUMBER_START, - VALID_MODELS, - XML_ID_PREFIX_REGEX, -} from "./constants"; +import { toStringSafe } from "../_internals/to-string-safe/to-string-safe"; /** - * The document models a DF-e access key can carry: `"55"` NF-e, `"57"` CT-e, `"58"` MDF-e, - * `"62"` NFCom, `"63"` BP-e, `"64"` GTV-e, `"65"` NFC-e, `"66"` NF3e and `"67"` CT-e OS. - * Spelled out instead of derived from `VALID_MODELS` because the allowlist is internal and API - * Extractor cannot name it in the public report; the type test of `parse-nfe-key.test.ts` pins - * the two together so they cannot drift apart. + * Drops the XML `Id` prefix a DF-e document writes in front of its access key, so the digit it + * may carry (the `3` of `NF3e`) is not read as part of the key. + * @param {string} value - The trimmed value to strip the prefix from. + * @returns {string} The value without its leading `NFe`/`CTe`/`MDFe`/`BPe`/`NF3e`/`NFCom` prefix. */ -export type NfeKeyModel = "55" | "57" | "58" | "62" | "63" | "64" | "65" | "66" | "67"; +const stripXmlIdPrefix = (value: string): string => { + const [prefix = ""] = XML_ID_PREFIX_REGEX.exec(value) ?? []; -/** The fields `parseNfeKey` reads out of a DF-e access key (chave de acesso). */ -export type NfeKey = { - /** Two letter code of the issuing state, read from the IBGE UF code. */ - state: StateCode; - /** Four digit issue year. */ - year: number; - /** Issue month, 1 to 12. */ - month: number; - /** The 14 digit CNPJ (or zero padded CPF) of the issuer. */ - taxId: string; - /** Document model: "55" NF-e, "57" CT-e, "58" MDF-e, "62" NFCom, "63" BP-e, "64" GTV-e, "65" NFC-e, "66" NF3e, "67" CT-e OS. */ - model: NfeKeyModel; - /** Document series, 0 to 999. */ - series: number; - /** Document number, 1 to 999999999. */ - number: number; - /** Emission type code (tpEmis), one of the codes the MOC of that model assigns. */ - emissionType: number; - /** - * Site of the authorizer that received the document (`nSiteAutoriz`), 0 to 9. Only NFCom - * (`"62"`) and NF3e (`"66"`) spend a digit of the key on it. - */ - authorizationSite?: number; - /** The numeric code (cNF) drawn by the issuer: 7 digits for NFCom and NF3e, 8 for the rest. */ - code: string; - /** The modulo 11 check digit of the key. */ - checkDigit: number; + return value.slice(prefix.length); }; -const EMISSION_TYPE_INDEX = 34; - -const AUTHORIZATION_SITE_INDEX = 35; - -const SHORT_CODE_START = 36; - -const CODE_END = 43; - -const CHECK_DIGIT_INDEX = 43; - -const isForbiddenCode = (model: string, code: string, number: number): boolean => - FORBIDDEN_CODE_MODELS.includes(model) && - (FORBIDDEN_CODES.includes(code) || Number(code) === number); - /** - * Parses a DF-e (Documento Fiscal eletrônico) access key (chave de acesso) into its fields. - * - * Covers every document whose access key is the same 44 digit string: NF-e (modelo 55), NFC-e - * (65), CT-e (57), MDF-e (58), CT-e OS (67), GTV-e (64), BP-e (63), NF3e (66) and NFCom (62). - * Accepts the same input forms as `isValidNfeKey` (whitespace mask, the `NFe`, `CTe`, `MDFe`, - * `BPe`, `NF3e` and `NFCom` prefixes of the XML `Id` attribute) and returns `null` when the key - * is not valid. + * Removes the formatting of a DF-e (Documento Fiscal eletrônico) access key (chave de acesso) and + * returns only digits. * - * The emission type (`tpEmis`) is checked against the codes the MOC of that model assigns, so - * the accepted set changes with the model: 1 to 7 and 9 for NF-e and NFC-e, `{1, 3, 4, 5, 7, 8}` - * for the CT-e, `{1, 5, 7, 8}` for the CT-e OS and `{1, 2, 7, 8}` for the GTV-e (8 is the - * authorização pela SVC-SP of the CT-e MOC), `{1, 2, 3}` for the MDF-e and `{1, 2}` for the - * BP-e, the NF3e and the NFCom. + * The `NFe`, `CTe`, `MDFe`, `BPe`, `NF3e` and `NFCom` prefixes the `Id` attribute of the + * document's XML puts in front of the key are stripped before the digits are read, with any + * whitespace around them, the same way `isValidNfeKey` accepts them. The prefix has to go first + * because `NF3e` carries a digit of its own that is not part of the key. * - * NFCom and NF3e write `nSiteAutoriz` in position 36 and only 7 digits of `cNF` after it, so - * `authorizationSite` is filled for those two models and `code` is 7 characters long instead of - * 8; every other model leaves `authorizationSite` out and reads an 8 digit `code`. + * The result is capped at the 44 digits of an access key; a shorter value passes through as far + * as it goes, so the grouping of a key still being typed can be stripped with it. Use + * `isValidNfeKey` to check the key and `getNfeKeyInfo` to read its fields. * - * For NF-e and NFC-e the numeric code is also checked against rule B03-10 of the NF-e MOC, - * which forbids the twenty repeated and sequential codes it lists and a `cNF` equal to the - * document number. That rule arrived with NT 2019.001, so it can turn down a key authorised - * before it, and no other MOC states it, which is why it is not applied to the other models. - * Rejecting a document number of all zeros, on the other hand, is a choice of this library: no - * MOC rule was found forbidding it. - * - * @param {string} value - The access key value to be parsed. - * @returns {NfeKey | null} The parsed access key, or `null` when it is not valid. - * - * @see Official: https://www.confaz.fazenda.gov.br/legislacao/arquivo-manuais/moc7-visao-geral.pdf - * Manual de Orientação do Contribuinte (MOC) NF-e, "chave de acesso". - * @see Official: https://www.confaz.fazenda.gov.br/legislacao/ajustes/2007/AJ_009_07 - * Ajuste SINIEF 09/07, cláusula primeira, § 3.º, II, "b": the CT-e OS, modelo 67. - * @see Official: https://www.cte.fazenda.gov.br/portal/listaManuais.aspx?tipoConteudo=manuais - * CT-e MOC 4.00, Anexo I: modelo 64 (GTV-e) and the `tpEmis` domains D19, D27 and D15. - * @see Official: https://dfe-portal.svrs.rs.gov.br/BPE/Documentos - * BP-e MOC 1.00b, Visão Geral and Anexo I: modelo 63. - * @see Official: https://dfe-portal.svrs.rs.gov.br/NF3e/Documentos - * NF3e MOC 1.00a, Visão Geral and Anexo I: modelo 66 and `nSiteAutoriz`. - * @see Official: https://dfe-portal.svrs.rs.gov.br/NFCOM/Documentos - * NFCom MOC 1.00a, Visão Geral and Anexo I: modelo 62 and `nSiteAutoriz`. - * @see Based on: https://github.com/nfephp-org/sped-common/blob/master/src/Keys.php - * NFePHP `Keys::build` reference implementation, source of the SP and RS test vectors. - * @see Based on: https://github.com/vmarchesin/br-validate-dfe-access-key - * Second reference implementation. + * @param {string|number} value - The access key value to be parsed. + * @returns {string} Up to 44 digits, or an empty string when there is no digit at all. * * @example * ```typescript - * parseNfeKey("35170458716523000119550010000000121000123458"); - * // { state: "SP", year: 2017, month: 4, taxId: "58716523000119", model: "55", - * // series: 1, number: 12, emissionType: 1, code: "00012345", checkDigit: 8 } + * parseNfeKey("3517 0458 7165 2300 0119 5500 1000 0000 1210 0012 3458"); + * // "35170458716523000119550010000000121000123458" * - * parseNfeKey("invalid"); // null + * parseNfeKey("NFe35170458716523000119550010000000121000123458"); + * // "35170458716523000119550010000000121000123458" * ``` + * + * @see Official: https://www.confaz.fazenda.gov.br/legislacao/arquivo-manuais/moc7-visao-geral.pdf + * Manual de Orientação do Contribuinte (MOC) NF-e, "chave de acesso", which fixes the 44 digits + * and the `Id` attribute the prefixes come from. */ -export const parseNfeKey = (value: string): NfeKey | null => { - if (typeof value !== "string") return null; - - const body = value.trim().replace(XML_ID_PREFIX_REGEX, ""); - - if (!FORMAT_REGEX.test(body)) return null; - - const digits = sanitizeToDigits(body); - - if (digits.length !== NFE_KEY_LENGTH) return null; - - const uf = digits.slice(0, 2); - - const state = IBGE_UF_CODES[uf]; - - if (state === undefined) return null; - - const month = Number(digits.slice(4, 6)); - - if (month < 1 || month > 12) return null; - - const modelDigits = digits.slice(20, 22); - const model = VALID_MODELS.find((candidate) => candidate === modelDigits); - - if (model === undefined) return null; - - if (digits.slice(NUMBER_START, NUMBER_END) === ABSENT_NUMBER) return null; - - const emissionType = Number(digits[EMISSION_TYPE_INDEX]); - - if (!EMISSION_TYPES_BY_MODEL[model].includes(emissionType)) return null; - - const hasAuthorizationSite = AUTHORIZATION_SITE_MODELS.includes(model); - const code = digits.slice( - hasAuthorizationSite ? SHORT_CODE_START : AUTHORIZATION_SITE_INDEX, - CODE_END, - ); - const number = Number(digits.slice(NUMBER_START, NUMBER_END)); - - if (isForbiddenCode(model, code, number)) return null; - - const checkDigit = Number(digits[CHECK_DIGIT_INDEX]); - - if (mod11(digits.slice(0, CHECK_DIGIT_INDEX), { variant: "arrecadacao" }) !== checkDigit) { - return null; - } - - const parsed: NfeKey = { - state, - year: 2000 + Number(digits.slice(2, 4)), - month, - taxId: digits.slice(6, 20), - model, - series: Number(digits.slice(22, 25)), - number, - emissionType, - code, - checkDigit, - }; - - if (hasAuthorizationSite) parsed.authorizationSite = Number(digits[AUTHORIZATION_SITE_INDEX]); - - return parsed; -}; +export const parseNfeKey = (value: string | number): string => + isNullish(value) + ? "" + : sanitizeToDigits(stripXmlIdPrefix(toStringSafe(value).trim())).slice(0, NFE_KEY_LENGTH); diff --git a/src/parse-passport/parse-passport.ts b/src/parse-passport/parse-passport.ts index 88971fb4..c0550562 100644 --- a/src/parse-passport/parse-passport.ts +++ b/src/parse-passport/parse-passport.ts @@ -14,6 +14,7 @@ import { sanitizeToAlphanumeric } from "../_internals/sanitize-to-alphanumeric/s * parsePassport("Ab -. 123456") // "AB123456" * * @see Official: https://www.gov.br/pf/pt-br/assuntos/passaporte + * @see Official: https://www.gov.br/pf/pt-br/assuntos/passaporte/ajuda/duvidas_/caderneta/caderneta-numero-onde-fica-e */ export const parsePassport = (passport: string): string => typeof passport === "string" ? sanitizeToAlphanumeric(passport).slice(0, PASSPORT_LENGTH) : ""; diff --git a/src/parse-processo-juridico/parse-processo-juridico.ts b/src/parse-processo-juridico/parse-processo-juridico.ts index 7642be59..d50760e5 100644 --- a/src/parse-processo-juridico/parse-processo-juridico.ts +++ b/src/parse-processo-juridico/parse-processo-juridico.ts @@ -10,7 +10,7 @@ import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-d * * @example * ```typescript - * parseProcessoJuridico("0002080-25.2026.5.15.0049"); // "00020802520265150049" + * parseProcessoJuridico("0002080-34.2026.5.15.0049"); // "00020803420265150049" * ``` * * Resolução CNJ nº 65/2008 defines this Número Único de Processo layout and its check digits. diff --git a/src/parse-voter-id/parse-voter-id.ts b/src/parse-voter-id/parse-voter-id.ts index 4d1ed719..a0d1834a 100644 --- a/src/parse-voter-id/parse-voter-id.ts +++ b/src/parse-voter-id/parse-voter-id.ts @@ -1,5 +1,4 @@ import { NINE_DIGIT_FEDERATIVE_UNION_CODES } from "../_internals/constants/voter-id"; -import { isNullish } from "../_internals/is-nullish/is-nullish"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; import { EXTENDED_LENGTH, LENGTH } from "./constants"; @@ -23,12 +22,13 @@ import { EXTENDED_LENGTH, LENGTH } from "./constants"; * 14-or-more-digit input whose 10th and 11th digits are "01"/"02" is read as a 13-digit São Paulo * or Minas Gerais id and capped at 13 digits, discarding anything past that. * + * The TSE resolution page sits behind a bot filter and answers HTTP 403 to every non-browser + * client, so it has to be opened in a browser. + * * @see Official: https://www.tse.jus.br/legislacao/compilada/res/2021/resolucao-no-23-659-de-26-de-outubro-de-2021 * @see Based on: https://github.com/brazilian-utils/python/blob/main/brutils/voter_id.py */ export const parseVoterId = (value: string | number): string => { - if (isNullish(value)) return ""; - const digits = sanitizeToDigits(value); const federativeUnion = digits.slice(9, 11); diff --git a/src/remove-accents/remove-accents.ts b/src/remove-accents/remove-accents.ts index 2217fa8b..230246fe 100644 --- a/src/remove-accents/remove-accents.ts +++ b/src/remove-accents/remove-accents.ts @@ -9,6 +9,9 @@ const COMBINING_MARKS_REGEX = /\p{M}/gu; * @returns {string} The text with every diacritical mark removed. `""` when `value` is not a * non-empty string. * + * @see Official: https://unicode.org/reports/tr15/ + * @see Official: https://www.unicode.org/reports/tr44/#General_Category_Values + * * @example * ```typescript * removeAccents("São Paulo"); // "Sao Paulo" diff --git a/src/sub-business-days/sub-business-days.test.ts b/src/sub-business-days/sub-business-days.test.ts new file mode 100644 index 00000000..f428e7a8 --- /dev/null +++ b/src/sub-business-days/sub-business-days.test.ts @@ -0,0 +1,149 @@ +import * as fc from "fast-check"; + +import { + anyBusinessDayAmount, + anyBusinessDayDate, + anyBusinessDayOptions, + businessDayDates, + PROTOTYPE_KEYS, +} from "../_internals/test/arbitraries"; +import { expectNeverThrowsWithArguments } from "../_internals/test/properties"; +import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; +import { addBusinessDays } from "../add-business-days/add-business-days"; +import { type BusinessDayOptions } from "../is-business-day/is-business-day"; +import { subBusinessDays } from "./sub-business-days"; + +const NULL_CALLS: [string, () => Date | null][] = [ + // @ts-expect-error: intentionally invalid input + ["no arguments at all", () => subBusinessDays()], + // @ts-expect-error: intentionally invalid input + ["a null date", () => subBusinessDays(null, 1)], + ["an invalid Date", () => subBusinessDays(new Date("not a date"), 1)], + // @ts-expect-error: intentionally invalid input + ["a date given as a string", () => subBusinessDays("2024-01-05", 1)], + ["an amount with a fractional part", () => subBusinessDays(new Date(2024, 0, 5), 1.5)], + ["an amount of NaN", () => subBusinessDays(new Date(2024, 0, 5), Number.NaN)], + ["an amount of -Infinity", () => subBusinessDays(new Date(2024, 0, 5), Number.NEGATIVE_INFINITY)], + // @ts-expect-error: intentionally invalid input + ["an amount given as a numeric string", () => subBusinessDays(new Date(2024, 0, 5), "1")], + // @ts-expect-error: intentionally invalid input + ["an amount given as null", () => subBusinessDays(new Date(2024, 0, 5), null)], + [ + "a stateCode that is not a string", + // @ts-expect-error: intentionally invalid input + () => subBusinessDays(new Date(2024, 0, 5), 1, { stateCode: 7 }), + ], + ["a date before the supported years", () => subBusinessDays(new Date(1899, 11, 29), 1)], + ["a walk that leaves 1900", () => subBusinessDays(new Date(1900, 0, 2, 12), 1)], + ["a walk that leaves 2099", () => subBusinessDays(new Date(2099, 11, 31, 12), -1)], +]; + +describe("subBusinessDays", () => { + it("should step back to the previous day when it is already a business day (Fri 2024-01-05 - 1 -> Thu 2024-01-04, noon)", () => { + expect(subBusinessDays(new Date(2024, 0, 5, 12), 1)).toEqual(new Date(2024, 0, 4, 12)); + }); + + it("should walk back over Saturday and Sunday (Mon 2024-01-08 - 1 -> Fri 2024-01-05)", () => { + expect(subBusinessDays(new Date(2024, 0, 8, 12), 1)).toEqual(new Date(2024, 0, 5, 12)); + }); + + it("should walk back over Ano novo and a year boundary (Thu 2025-01-02 - 1 -> Tue 2024-12-31)", () => { + expect(subBusinessDays(new Date(2025, 0, 2, 12), 1)).toEqual(new Date(2024, 11, 31, 12)); + }); + + it("should count several business days back at once (Fri 2024-01-12 - 5 -> Fri 2024-01-05)", () => { + expect(subBusinessDays(new Date(2024, 0, 12, 12), 5)).toEqual(new Date(2024, 0, 5, 12)); + }); + + it("should walk forwards for a negative amount (Fri 2024-01-05 - -1 -> Mon 2024-01-08)", () => { + expect(subBusinessDays(new Date(2024, 0, 5, 12), -1)).toEqual(new Date(2024, 0, 8, 12)); + }); + + it("should return a new Date equal to the input for an amount of 0, weekend or not", () => { + const saturday = new Date(2024, 0, 6, 12); + const result = subBusinessDays(saturday, 0); + + expect(result).toEqual(new Date(2024, 0, 6, 12)); + expect(result).not.toBe(saturday); + }); + + it("should keep the time-of-day of the input and leave the input untouched", () => { + const input = new Date(2024, 0, 5, 9, 30, 15, 500); + const result = subBusinessDays(input, 1); + + expect(result).toEqual(new Date(2024, 0, 4, 9, 30, 15, 500)); + expect(input).toEqual(new Date(2024, 0, 5, 9, 30, 15, 500)); + }); + + describe("state holidays", () => { + it("should walk back over a state holiday when stateCode is given (SP, Revolução Constitucionalista 2024-07-09)", () => { + const result = subBusinessDays(new Date(2024, 6, 10, 12), 1, { stateCode: "SP" }); + + expect(result).toEqual(new Date(2024, 6, 8, 12)); + }); + + it("should land on that same holiday without a stateCode", () => { + expect(subBusinessDays(new Date(2024, 6, 10, 12), 1)).toEqual(new Date(2024, 6, 9, 12)); + }); + + it("should treat a prototype chain key as an unknown stateCode instead of throwing", () => { + for (const stateCode of PROTOTYPE_KEYS) { + // @ts-expect-error: intentionally invalid input + expect(subBusinessDays(new Date(2024, 0, 5, 12), 1, { stateCode })).toEqual( + new Date(2024, 0, 4, 12), + ); + } + }); + + it("should ignore options that are not an object", () => { + // @ts-expect-error: intentionally invalid input + expect(subBusinessDays(new Date(2024, 6, 10, 12), 1, "SP")).toEqual(new Date(2024, 6, 9, 12)); + }); + }); + + describe("includeOptional", () => { + it("should walk back over Carnaval 2024-02-13 by default (Wed 2024-02-14 - 1 -> Mon 2024-02-12)", () => { + expect(subBusinessDays(new Date(2024, 1, 14, 12), 1)).toEqual(new Date(2024, 1, 12, 12)); + }); + + it("should stop on Carnaval 2024-02-13 when includeOptional is false", () => { + const result = subBusinessDays(new Date(2024, 1, 14, 12), 1, { includeOptional: false }); + + expect(result).toEqual(new Date(2024, 1, 13, 12)); + }); + }); + + describe("invalid input", () => { + for (const [label, call] of NULL_CALLS) { + it(`should return null for ${label}`, () => { + expect(call()).toBeNull(); + }); + } + }); + + describe("properties", () => { + test("should never throw, regardless of the input, prototype chain state codes included", () => { + expectNeverThrowsWithArguments( + subBusinessDays, + fc.tuple(anyBusinessDayDate, anyBusinessDayAmount, anyBusinessDayOptions), + ); + }); + + test("should be addBusinessDays with the opposite amount", () => { + fc.assert( + fc.property(businessDayDates, fc.integer({ min: -200, max: 200 }), (date, amount) => { + expect(subBusinessDays(date, amount)).toEqual(addBusinessDays(date, -amount)); + }), + ); + }); + }); +}); + +describe("subBusinessDays types", () => { + test("should take a Date, a number and optional BusinessDayOptions, and return a Date or null", () => { + expectTypeOf(subBusinessDays).parameter(0).toEqualTypeOf(); + expectTypeOf(subBusinessDays).parameter(1).toEqualTypeOf(); + expectTypeOf(subBusinessDays).parameter(2).toEqualTypeOf(); + expectTypeOf(subBusinessDays).returns.toEqualTypeOf(); + }); +}); diff --git a/src/sub-business-days/sub-business-days.ts b/src/sub-business-days/sub-business-days.ts new file mode 100644 index 00000000..664c6fbd --- /dev/null +++ b/src/sub-business-days/sub-business-days.ts @@ -0,0 +1,65 @@ +import { addBusinessDays } from "../add-business-days/add-business-days"; +import { type BusinessDayOptions } from "../is-business-day/is-business-day"; + +export type { BusinessDayOptions } from "../is-business-day/is-business-day"; + +/** + * Subtracts a number of Brazilian business days (dias úteis) from a date. + * + * The mirror image of `addBusinessDays`, which it delegates to: `subBusinessDays(date, amount)` + * is `addBusinessDays(date, -amount)`, down to the last detail. A business day is a day for + * which `isBusinessDay` returns `true` (not a Saturday, a Sunday, or a Brazilian holiday), + * evaluated with the same `options`, and the walk goes one calendar day at a time, counting only + * business days. + * + * `amount: 0` returns a **new `Date` equal to `date`, unchanged**, even when `date` itself falls + * on a weekend or holiday, and a negative `amount` walks *forwards*, exactly like date-fns' + * `subBusinessDays`. + * + * The time-of-day (hours, minutes, seconds, milliseconds) of `date` is preserved in the result, + * and `date` itself is never mutated. + * + * If `options.stateCode` is provided but is not a valid/known state code, it is ignored and only + * national holidays are considered (same behavior as `getHolidays`/`isBusinessDay`), so a + * prototype-chain key such as `"__proto__"` is an unknown state code like any other. An `options` + * that is not an object at all is ignored, exactly as `isBusinessDay` ignores it. + * + * Only years from 1900 through 2099 are supported, the range `getHolidays` computes. A `date` + * outside it, or a walk that leaves it, returns `null`. + * + * @param {Date} date - The date to count from. Never mutated: a new `Date` is returned. + * @param {number} amount - The number of business days to subtract; a negative value walks forwards. + * @param {BusinessDayOptions} [options] - Which holidays count as non-business days. + * @param {StateCode} [options.stateCode] - Brazilian state code whose state holidays are also considered. + * @param {boolean} [options.includeOptional] - Whether optional holidays count as non-business days (default: `true`). + * @returns {Date | null} A new `Date`, `amount` business days before `date`. `null` on bad input: + * a `date` that is not a valid `Date` or is outside 1900-2099, an `amount` that is not a finite + * integer, a `stateCode` that is not a string, or a walk that leaves the supported years. + * + * @example + * ```typescript + * subBusinessDays(new Date(2024, 0, 5, 12), 1); // Thu 2024-01-04, 12:00 (the previous day is already a business day) + * subBusinessDays(new Date(2024, 0, 8, 12), 1); // Fri 2024-01-05, 12:00 (walks back over the weekend) + * subBusinessDays(new Date(2025, 0, 2, 12), 1); // Tue 2024-12-31, 12:00 (Jan 1 is Ano novo, skipped) + * subBusinessDays(new Date(2024, 0, 5, 12), -1); // Mon 2024-01-08, 12:00 (walks forwards) + * subBusinessDays(new Date(2024, 0, 6, 12), 0); // Sat 2024-01-06, 12:00 (unchanged, even though Saturday is not a business day) + * subBusinessDays(new Date(2024, 6, 10, 12), 1, { stateCode: "SP" }); // Mon 2024-07-08, 12:00 (Jul 9 is a state holiday in SP) + * subBusinessDays(new Date("not a date"), 1); // null + * subBusinessDays(new Date(2024, 0, 2), 1.5); // null (not an integer) + * subBusinessDays(new Date(1900, 0, 2), 1); // null (the walk leaves the supported years) + * ``` + * + * @see Based on: https://date-fns.org/docs/subBusinessDays + * Reference behavior and the positional + * `(date, amount)` argument order. The underlying holiday determination's official sources are + * cited in `isBusinessDay`/`getHolidays`. + */ +export const subBusinessDays = ( + date: Date, + amount: number, + options?: BusinessDayOptions, +): Date | null => { + if (!Number.isInteger(amount)) return null; + + return addBusinessDays(date, -amount, options); +}; diff --git a/vite.config.ts b/vite.config.ts index 7ca2b736..9025502c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -132,7 +132,7 @@ const sharedPack = { * shipped), and the ~76 util subpaths are built together in a second, separate invocation. Within * that second group, rolldown's default splitting still applies *among the utils themselves*: * most end up self-contained (single importer within that graph), but a few genuine cross-util - * dependencies (e.g. `parsePixKey` reusing `isValidCpf`'s digit-check, several phone utils sharing + * dependencies (e.g. `getPixKeyInfo` reusing `isValidCpf`'s digit-check, several phone utils sharing * `formatPhone`'s area-code table) get factored into a small shared chunk, real code reuse that * would otherwise be duplicated; either way, none of it touches the root. Running * ~77 entries as ~77 *separate* `PackUserConfig`s (fully self-contained, zero sharing at all) was @@ -145,7 +145,9 @@ const sharedPack = { export default defineConfig({ fmt: { - ignorePatterns: ["dist", "coverage", "docs", ".claude"], + // `reports` holds generated output only, the committed API Extractor baseline included: + // reformatting its code block would make every `check:api` run report a changed API. + ignorePatterns: ["dist", "coverage", "docs", "reports", ".stryker-tmp", ".claude"], singleQuote: false, sortImports: true, useTabs: true, @@ -451,8 +453,17 @@ export default defineConfig({ "vitest/warn-todo": "off", }, }, + // The barrel re-exports every deprecated alias, and a deprecated util's own tests (plus + // the `getMunicipalities` property that cross-checks it against `getCities`) have to + // keep calling it for as long as it is still supported. { - files: ["src/index.ts", "src/index.test.ts"], + files: [ + "src/index.ts", + "src/index.test.ts", + "src/get-cities/get-cities.test.ts", + "src/get-municipalities/get-municipalities.test.ts", + "src/get-municipality/get-municipality.test.ts", + ], rules: { "typescript/no-deprecated": "off", },