diff --git a/.all-contributorsrc b/.all-contributorsrc index 46feee155..350b53d5c 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -30,7 +30,7 @@ "login": "matAlmeida", "name": "Matheus Almeida", "avatar_url": "https://avatars3.githubusercontent.com/u/12724212?v=4", - "profile": "http://matalmeida.me", + "profile": "https://github.com/matAlmeida", "contributions": ["code", "doc", "test"] }, { @@ -100,7 +100,7 @@ "login": "rfoel", "name": "Rafael Franco", "avatar_url": "https://avatars3.githubusercontent.com/u/19496473?v=4", - "profile": "https://rfoel.com", + "profile": "https://github.com/rfoel", "contributions": ["code", "doc"] }, { @@ -163,7 +163,7 @@ "login": "marceloabk", "name": "Marcelo Cristiano", "avatar_url": "https://avatars3.githubusercontent.com/u/11621153?v=4", - "profile": "http://www.engenhandosoftware.com.br/", + "profile": "https://github.com/marceloabk", "contributions": ["code", "doc", "test"] }, { diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..7ba5156e8 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,8 @@ +# Code owners for this repository. +# +# @hyanmandian is the sole maintainer, so one catch-all rule covers everything. +# GitHub requests a review from the owner on every pull request that touches a +# matching path; combined with "Require review from Code Owners" in the branch +# protection rules for `main`, it makes that review mandatory. + +* @hyanmandian diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 76199ca64..97fb860fc 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 @@ -77,5 +77,5 @@ body: options: - label: I searched existing issues and this hasn't been reported yet. required: true - - label: I'm using a version listed in [SECURITY.md](../../SECURITY.md#supported-versions) as supported. + - label: I'm using a version listed in [SECURITY.md](https://github.com/brazilian-utils/javascript/blob/main/SECURITY.md#supported-versions) as supported. required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 56b68a5a3..ca59c2741 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 000000000..d7b9d94ed --- /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 cbcbedc61..4306ce530 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 80466e04e..18b1480ae 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 16197652a..d261f644b 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 83458c491..e259223c1 100644 --- a/.github/workflows/live-tests.yml +++ b/.github/workflows/live-tests.yml @@ -22,8 +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 - continue-on-error: false diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index 1d2e29c90..07d153027 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -26,11 +26,20 @@ jobs: persist-credentials: false - name: Setup - uses: ./.github/actions/setup + uses: $/.github/actions/setup - name: Mutation test every file + if: ${{ github.event_name != 'push' }} run: npm run test:mutation + # On `main` the dashboard reporter also publishes the score to dashboard.stryker-mutator.io; + # pull requests keep the local reporters only, so a fork never needs the key. + - name: Mutation test every file and publish the score + if: ${{ github.event_name == 'push' }} + env: + STRYKER_DASHBOARD_API_KEY: ${{ secrets.STRYKER_DASHBOARD_API_KEY }} + run: npm run test:mutation -- --reporters clear-text,progress,html,json,dashboard + - name: Upload the mutation report if: ${{ always() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a816df8b2..058f54169 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 67a5b57b5..b16427c58 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 895e7d16b..7465c7a3a 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 5dffb64b4..60aeea59b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,11 @@ 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). +The entries are generated by [release-please](https://github.com/googleapis/release-please) from the +[Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) history, 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 +194,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 f200213e8..788426427 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,24 +28,31 @@ 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:tree-shaking` | Builds nothing; measures the single-import size of every export against `dist` (`scripts/tree-shaking.ts`). Run it after `npm run build` when you change a dataset, and update the bundle-size table in `docs/getting-started.md` / `docs/pt-br/getting-started.md`. | +| `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 bench` | Runs the `describe("… benchmarks")` blocks with vitest in benchmark mode (`vp test bench --run`); they register nothing in test mode. | +| `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 @@ -64,11 +71,18 @@ example `formatSomething`): wiring needed. That subpath lets consumers lazy-load a single heavy util (see `getCities` in [Bundle size](docs/getting-started.md#bundle-size)) without touching the root bundle. 2. Add the implementation in `src/format-something/format-something.ts`. If the function takes an - options object, type it as `FormatSomethingOptions` (i.e. the function's `PascalCase` name plus - `Options`) and export it alongside the function. Write a JSDoc comment (description, `@param`, - `@returns`, `@example`, and an `@see` link to the authoritative source when the utility - implements an official Brazilian specification/algorithm (e.g. a Bacen manual, an IBGE table, - a government validation algorithm) following the style used in the existing utilities (see + object argument, name its type after the function's `PascalCase` name plus the suffix that says + which argument it is: `FormatSomethingOptions` for a second, usually optional, options object + (`formatSomething(value, options?)`), and `FormatSomethingParams` for the object that is the + function's only (or first and only object) argument (`formatSomething(params)`). Export it + alongside the function. The rule has no exceptions: the 2.3.0 names that broke it + (`GenerateProcessoJuridicoOptions`, `GetCepInfoByAddressOptions`, `GetHolidaysOptions`, + `IsHolidayOptions`, `IsValidBankAccountOptions` and the three `GetMunicipality*Options`) are + now `@deprecated` aliases of the rule-compliant `*Params` names, and go away in v3. Write a + JSDoc comment (description, `@param`, `@returns`, `@example`, and an `@see` link to the + authoritative source when the utility implements an official Brazilian + specification/algorithm (e.g. a Bacen manual, an IBGE table, a government validation + algorithm) following the style used in the existing utilities (see `src/format-cpf/format-cpf.ts` for a reference). Keep the module tree-shakeable: no top-level allocations or calls (`new Map()`, `new Set()`, etc.) that a bundler cannot prove side-effect free, since those pin the module into every bundle that imports any util from the package. @@ -110,8 +124,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 @@ -206,16 +224,24 @@ pull request so the CI result is not a surprise. means a test is missing (add one, with a literal expectation) or the code has a branch that can never matter (simplify it). Only when a mutant is truly equivalent, use `// Stryker disable next-line : ` right above the line; that is the one - place an inline comment is accepted in this codebase. + place an inline comment is accepted in this codebase. The config sets `tsconfigFile` to an empty + string because Stryker's sandbox preprocessor rewrites the `tsconfig.json` it finds through + `ts.parseConfigFileTextToJson`, which TypeScript 7 no longer exposes; an empty value skips that + preprocessor, and the sandbox does not need the tsconfig since vitest transpiles the sources + itself (`stryker.config.json` is parsed as strict JSON, so the note cannot live in the file). ## Public API validation [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 @@ -293,9 +319,10 @@ There are no local release commands to run. or a `BREAKING CHANGE:` footer bumps the major version. The release PR's description and the `CHANGELOG.md` entry it adds are generated from the commit subjects/bodies, so writing a clear, accurately-typed commit message matters. `release-please-config.json` maps the types to the - changelog sections: `feat`, `fix`, `perf`, `revert`, `docs`, `build`, `ci`, `deps`/`chore(deps)` - and `chore(data)` (the dataset refreshes) are listed; `chore`, `test`, `refactor` and `style` - stay hidden. + changelog sections: `feat`, `fix`, `perf`, `revert`, `docs` and `chore(data)` (the dataset + refreshes) are listed; `build`, `ci`, `chore` (including the Dependabot `chore(deps)` and + `chore(deps-dev)` bumps, which only touch the toolchain), `test`, `refactor` and `style` stay + hidden. 2. A maintainer reviews the release PR (version bump, changelog) and merges it. **Merging the release PR is the first confirmation.** Nothing is published yet at this point. 3. Merging tags the release and publishes a GitHub Release, which triggers the `publish` job in diff --git a/README.md b/README.md index 2423b6d23..90dc26631 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/badge/isValidCpf%20import-0.8%20KB%20gzip-brightgreen)](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. @@ -74,7 +76,14 @@ or ` - - - - - + + + + + + + - \ No newline at end of file + diff --git a/docs/llms-full.txt b/docs/llms-full.txt index f4e491e22..c853d7ebc 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) @@ -17,42 +18,52 @@ - [isValidCnpj](#isvalidcnpj) - [formatCnpj](#formatcnpj) - [parseCnpj](#parsecnpj) - - [isValidCep](#isvalidcep) - [generateCnpj](#generatecnpj) + - [isValidCep](#isvalidcep) + - [formatCep](#formatcep) + - [parseCep](#parsecep) + - [generateCep](#generatecep) + - [getAddressInfoByCep](#getaddressinfobycep) + - [getCepInfoByAddress](#getcepinfobyaddress) - [isValidBoleto](#isvalidboleto) - [formatBoleto](#formatboleto) - [parseBoleto](#parseboleto) - [generateBoleto](#generateboleto) - [getBoletoInfo](#getboletoinfo) - [isValidPixKey](#isvalidpixkey) - - [parsePixKey](#parsepixkey) + - [getPixKeyInfo](#getpixkeyinfo) - [isValidPixPayload](#isvalidpixpayload) - - [parsePixPayload](#parsepixpayload) + - [getPixPayloadInfo](#getpixpayloadinfo) - [generatePixPayload](#generatepixpayload) - [isValidNfeKey](#isvalidnfekey) - [formatNfeKey](#formatnfekey) - [parseNfeKey](#parsenfekey) - - [isValidEmail](#isvalidemail) + - [getNfeKeyInfo](#getnfekeyinfo) - [isValidPhone](#isvalidphone) - [formatPhone](#formatphone) - [parsePhone](#parsephone) + - [generatePhone](#generatephone) - [isValidMobilePhone](#isvalidmobilephone) - [isValidLandlinePhone](#isvalidlandlinephone) - [isValidServicePhone](#isvalidservicephone) - [getAreaCodeInfo](#getareacodeinfo) - [getAreaCodesByState](#getareacodesbystate) - [isValidLicensePlate](#isvalidlicenseplate) + - [formatLicensePlate](#formatlicenseplate) + - [parseLicensePlate](#parselicenseplate) + - [generateLicensePlate](#generatelicenseplate) + - [getFormatLicensePlate](#getformatlicenseplate) + - [convertLicensePlateToMercosul](#convertlicenseplatetomercosul) - [isValidRenavam](#isvalidrenavam) + - [generateRenavam](#generaterenavam) - [isValidPis](#isvalidpis) - [formatPis](#formatpis) - [parsePis](#parsepis) - - [formatCep](#formatcep) - - [parseCep](#parsecep) - - [getAddressInfoByCep](#getaddressinfobycep) + - [generatePis](#generatepis) - [isValidProcessoJuridico](#isvalidprocessojuridico) - [formatProcessoJuridico](#formatprocessojuridico) - [parseProcessoJuridico](#parseprocessojuridico) - - [isValidIe](#isvalidie) + - [generateProcessoJuridico](#generateprocessojuridico) - [isValidBankAccount](#isvalidbankaccount) - [getBanks](#getbanks) - [getBankByCode](#getbankbycode) @@ -60,80 +71,84 @@ - [isValidIban](#isvalidiban) - [formatIban](#formatiban) - [parseIban](#parseiban) - - [isValidCreditCard](#isvalidcreditcard) - - [capitalize](#capitalize) + - [getIbanInfo](#getibaninfo) - [formatCurrency](#formatcurrency) - [parseCurrency](#parsecurrency) - [convertNumberToWords](#convertnumbertowords) - [convertCurrencyToWords](#convertcurrencytowords) + - [convertDateToWords](#convertdatetowords) - [getStates](#getstates) - [getStateByIbgeCode](#getstatebyibgecode) - [getStateCodeByName](#getstatecodebyname) - [getStateNameByCode](#getstatenamebycode) - [getTimezoneByState](#gettimezonebystate) + - [getMunicipalities](#getmunicipalities) + - [getMunicipalityByCode](#getmunicipalitybycode) - [getCities](#getcities) + - [getMunicipality](#getmunicipality) - [getHolidays](#getholidays) + - [isHoliday](#isholiday) + - [isBusinessDay](#isbusinessday) + - [addBusinessDays](#addbusinessdays) + - [subBusinessDays](#subbusinessdays) + - [differenceInBusinessDays](#differenceinbusinessdays) - [isValidPassport](#isvalidpassport) - [formatPassport](#formatpassport) - - [generatePassport](#generatepassport) - [parsePassport](#parsepassport) - - [generateCep](#generatecep) - - [formatCnh](#formatcnh) + - [generatePassport](#generatepassport) - [isValidCnh](#isvalidcnh) - - [generateCnh](#generatecnh) + - [formatCnh](#formatcnh) - [parseCnh](#parsecnh) - - [getCepInfoByAddress](#getcepinfobyaddress) - - [generateProcessoJuridico](#generateprocessojuridico) - - [formatLegalNature](#formatlegalnature) + - [generateCnh](#generatecnh) - [isValidLegalNature](#isvalidlegalnature) - - [generateLegalNature](#generatelegalnature) + - [formatLegalNature](#formatlegalnature) - [parseLegalNature](#parselegalnature) - - [getLegalNatures](#getlegalnatures) + - [generateLegalNature](#generatelegalnature) - [getLegalNature](#getlegalnature) - - [generatePhone](#generatephone) - - [formatLicensePlate](#formatlicenseplate) - - [generateLicensePlate](#generatelicenseplate) - - [getFormatLicensePlate](#getformatlicenseplate) - - [parseLicensePlate](#parselicenseplate) - - [convertLicensePlateToMercosul](#convertlicenseplatetomercosul) - - [generatePis](#generatepis) - - [getMunicipality](#getmunicipality) - - [getMunicipalities](#getmunicipalities) - - [getMunicipalityByCode](#getmunicipalitybycode) - - [isHoliday](#isholiday) - - [isBusinessDay](#isbusinessday) - - [addBusinessDays](#addbusinessdays) - - [differenceInBusinessDays](#differenceinbusinessdays) - - [convertDateToWords](#convertdatetowords) - - [formatVoterId](#formatvoterid) + - [getLegalNatures](#getlegalnatures) + - [getLegalNaturesByCategory](#getlegalnaturesbycategory) - [isValidVoterId](#isvalidvoterid) - - [generateVoterId](#generatevoterid) + - [formatVoterId](#formatvoterid) - [parseVoterId](#parsevoterid) + - [generateVoterId](#generatevoterid) - [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) - - [isValidRegistroProfissional](#isvalidregistroprofissional) - - [isValidVin](#isvalidvin) + - [parseCaepf](#parsecaepf) - [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) + - [capitalize](#capitalize) - [removeAccents](#removeaccents) + - [isValidIe](#isvalidie) + - [isValidEmail](#isvalidemail) + - [isValidCreditCard](#isvalidcreditcard) + - [isValidRegistroProfissional](#isvalidregistroprofissional) + - [isValidVin](#isvalidvin) ## Getting Started @@ -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.2 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` · `getBankByIspb` | 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,15 +249,15 @@ 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. +### CPF -### isValidCpf +#### isValidCpf Check if CPF is valid. Accepts the usual mask characters and whitespace between/around groups. @@ -253,9 +268,9 @@ isValidCpf('155151475'); // false isValidCpf('111 444 777 35'); // true (whitespace mask) ``` -### formatCpf +#### 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'; @@ -265,7 +280,7 @@ formatCpf('746506880', { pad: true }); // 007.465.068-80 formatCpf('12345678909', { obfuscate: true }); // ***.456.789-** ``` -### parseCpf +#### parseCpf Remove CPF formatting, keep only digits, and cap the result to 11 digits. @@ -275,9 +290,9 @@ import { parseCpf } from '@brazilian-utils/brazilian-utils'; parseCpf('746.506.880-00'); // 74650688000 ``` -### generateCpf +#### 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' @@ -286,9 +301,11 @@ generateCpf(); generateCpf('SP'); // the 9th digit is 8, the SP região fiscal code ``` -### isValidCnpj +### CNPJ -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`. +#### isValidCnpj + +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'; @@ -297,9 +314,9 @@ isValidCnpj('15515147234255'); // false isValidCnpj('q0slfmbd7vx439', { version: 2 }); // true (lowercase alphanumeric) ``` -### formatCnpj +#### 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'; @@ -310,9 +327,9 @@ formatCnpj('12OUT345000199', { version: 2 }); // 12.OUT.345/0001-99 formatCnpj('12345678000195', { obfuscate: true }); // **.345.678/0001-** ``` -### parseCnpj +#### 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'; @@ -321,9 +338,24 @@ parseCnpj('24.522.200/0001-74'); // 24522200000174 parseCnpj('12.OUT.345/0001-99', { version: 2 }); // 12OUT345000199 ``` -### isValidCep +#### generateCnpj + +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 `GenerateCnpjParams` 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' +``` + +### CEP and address -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. +#### 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, 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'; @@ -337,20 +369,94 @@ isValidCep('9250000A'); // false (letters are rejected) isValidCep('12345'); // false (invalid length) ``` -### generateCnpj +#### formatCep -Generate a valid random CNPJ. +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 { generateCnpj } from '@brazilian-utils/brazilian-utils' +import { formatCep } from '@brazilian-utils/brazilian-utils'; -generateCnpj(); -generateCnpj(2); // alphanumeric CNPJ, e.g. 'Q0SLFMBD7VX439' +formatCep('92500000'); // 92500-000 +formatCep('9250000', { pad: true }); // 09250-000 +``` + +#### parseCep + +Remove CEP formatting, keep only digits, and cap the result to 8 digits. + +```javascript +import { parseCep } from '@brazilian-utils/brazilian-utils'; + +parseCep('92500-000'); // 92500000 +``` + +#### generateCep + +Generate a random CEP. Uses `Math.random()` internally, so it is not cryptographically secure. + +```javascript +import { generateCep } from '@brazilian-utils/brazilian-utils'; + +generateCep(); // '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`. 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'; + +// Using the default providers (['viacep', 'brasilapi']) +const address = await getAddressInfoByCep('01310100'); +// { cep: '01310100', state: 'SP', city: 'São Paulo', neighborhood: 'Bela Vista', street: 'Avenida Paulista' } + +// Using specific providers +const addressFromProviders = await getAddressInfoByCep('01310-100', { + providers: ['viacep', 'brasilapi'] +}); + +// Using number input (will be padded automatically) +const addressFromNumber = await getAddressInfoByCep(1310100); +``` + +#### getCepInfoByAddress + +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: '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' +// } +// ] ``` -### isValidBoleto +### Boleto + +#### 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'; @@ -359,9 +465,9 @@ isValidBoleto('00190000090114971860168524522114675860000102656'); // true isValidBoleto('846100000005246100291102005460339004695895061080'); // true (boleto de arrecadação) ``` -### formatBoleto +#### 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'; @@ -372,7 +478,7 @@ formatBoleto('846100000005246100291102005460339004695895061080'); // 84610000000 formatBoleto('84610000000246100291100054603390069589506108'); // 84610.00000 02461.002911 00054.603390 0 69589506108 (44 digit arrecadação barcode keeps the bancária mask) ``` -### parseBoleto +#### parseBoleto Remove boleto formatting, keep only digits, and cap the result to 47 digits (48 for boleto de arrecadação). @@ -382,9 +488,9 @@ import { parseBoleto } from '@brazilian-utils/brazilian-utils'; parseBoleto('00190.00009 01149.718601 68524.522114 6 75860000102656'); // 00190000090114971860168524522114675860000102656 ``` -### generateBoleto +#### 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 `GenerateBoletoParams`) 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'; @@ -393,9 +499,9 @@ generateBoleto(); // "00190000090114971860168524522114675860000102656" generateBoleto({ type: 'arrecadacao' }); // "846100000005246100291102005460339004695895061080" ``` -### getBoletoInfo +#### 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,9 +516,13 @@ getBoletoInfo('00190000090114971860168524522114675860000102656', { getBoletoInfo('846100000005246100291102005460339004695895061080'); // { amount: 2461, expirationDate: null, bankCode: '', type: 'arrecadacao', segment: 4, value: 24.61, hasEffectiveValue: true } + +getBoletoInfo('invalid'); // null ``` -### isValidPixKey +### Pix + +#### 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. The manual registers a "número de telefone celular", so a landline is not a valid phone key. `options.accept` (typed as `IsValidPixKeyOptions`) restricts which kinds of key are accepted; it defaults to all of them, and `[]` rejects everything. Exports the `PixKeyType` type. @@ -428,26 +538,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 +#### 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 +570,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 +#### 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 `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. 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'; @@ -505,72 +616,97 @@ generatePixPayload({ generatePixPayload({ merchantName: 'Fulano', merchantCity: 'Brasília' }); // null (neither key nor url) ``` -### isValidNfeKey +### NF-e key -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. +#### isValidNfeKey + +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 +#### 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 +#### 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', -// 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' ``` -### isValidEmail +#### getNfeKeyInfo -Check if email is valid. +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 { isValidEmail } from '@brazilian-utils/brazilian-utils'; +import { getNfeKeyInfo } from '@brazilian-utils/brazilian-utils'; -isValidEmail('john.doe@hotmail.com'); // true +getNfeKeyInfo('35170458716523000119550010000000121000123458'); +// { stateCode: 'SP', year: 2017, month: 4, taxId: '58716523000119', model: '55', +// series: 1, number: 12, emissionType: 1, code: '00012345', checkDigit: 8 } + +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 ``` -### isValidPhone +### Phone + +#### 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 isValidPhone('11900000000', { accept: [] }); // false ``` -### formatPhone +#### 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 +714,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 @@ -586,7 +724,7 @@ formatPhone('+5511987654321', { mask: 'auto' }); // +55 11 98765-4321 ("auto" de formatPhone('11900000000'); // 11900-0000 (BEWARE: default "sn" truncates a DDD-prefixed number) ``` -### parsePhone +#### parsePhone Remove phone formatting, keep only digits, and cap the result to 11 digits. A Brazilian country code is stripped first, but only when the digits left behind are exactly 10 or 11 long, i.e. a plausible national number. The rule is length-based, not sign-based, so a number from area code 55 is not mistaken for a country code. @@ -599,19 +737,34 @@ parsePhone('5511987654321'); // 11987654321 parsePhone('55987654321'); // 55987654321 (area code 55, not mistaken for the +55 country code) ``` -### isValidMobilePhone +#### 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. A generated mobile number always starts with 9, so it passes both `isValidMobilePhone` numbering rules. + +```javascript +import { generatePhone } from '@brazilian-utils/brazilian-utils'; + +generatePhone(); // '11912345678' or '1131234567' +generatePhone('mobile'); // '11912345678' +generatePhone('landline'); // '1131234567' +generatePhone('service'); // '08001234567' or '40041234' +``` + +#### 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 +#### isValidLandlinePhone Check if landline phone number is valid. @@ -621,9 +774,9 @@ import { isValidLandlinePhone } from '@brazilian-utils/brazilian-utils'; isValidLandlinePhone('1130000000'); // true ``` -### isValidServicePhone +#### 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'; @@ -634,30 +787,30 @@ isValidServicePhone('190'); // true isValidServicePhone('11987654321'); // false (geographic number) ``` -### getAreaCodeInfo +#### 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. 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 getAreaCodeInfo(1.1); // null ``` -### getAreaCodesByState +#### getAreaCodesByState Get every DDD (area code) that serves a given Brazilian state, under the Anatel Plano Geral de Numeração. The match is case-insensitive and the result is sorted in ascending order. @@ -674,7 +827,9 @@ getAreaCodesByState('SC'); // [42, 47, 48, 49] getAreaCodesByState('XX'); // [] ``` -### isValidLicensePlate +### License plate + +#### isValidLicensePlate Check if license plate is valid. Supports the old Brazilian format (ABC-1234) and the Mercosul format (ABC1D23), the single sequence Resolução CONTRAN nº 969/2022 defines for every vehicle, motorcycles included. @@ -689,110 +844,167 @@ isValidLicensePlate('ABC12D3'); // false (not a Mercosul sequence) isValidLicensePlate('ABC1234EXTRA'); // false (too many characters) ``` -### isValidRenavam +#### formatLicensePlate -Check if RENAVAM (Registro Nacional de Veículos Automotores) is valid. Supports both the old format (9 digits) and the new format (11 digits). +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 { isValidRenavam } from '@brazilian-utils/brazilian-utils'; +import { formatLicensePlate } from '@brazilian-utils/brazilian-utils'; -isValidRenavam('639884962'); // true (9 digits, old format) -isValidRenavam('00639884962'); // true (11 digits, new format) -isValidRenavam('12345678901'); // false (invalid checksum) +formatLicensePlate('abc1234'); // 'ABC-1234' +formatLicensePlate('abc1d23'); // 'ABC1D23' ``` -### isValidPis +#### parseLicensePlate -Check if PIS is valid. Accepts the usual mask characters (`.`, `-`, `/`, `(`, `)`, `,`, `*`) and whitespace. +Remove separators from a license plate, normalize it to uppercase, and cap it to 7 characters. ```javascript -import { isValidPis } from '@brazilian-utils/brazilian-utils'; +import { parseLicensePlate } from '@brazilian-utils/brazilian-utils'; -isValidPis('12056412547'); // false +parseLicensePlate('abc-1234'); // 'ABC1234' ``` -### formatPis +#### generateLicensePlate -Format PIS number. +Generate a random license plate in the chosen format. Uses `Math.random()` internally, so it is not cryptographically secure. ```javascript -import { formatPis } from '@brazilian-utils/brazilian-utils'; +import { generateLicensePlate } from '@brazilian-utils/brazilian-utils'; -formatPis('12345678901'); // 123.45678.90-1 -formatPis('123456789', { pad: true }); // 001.23456.78-9 +generateLicensePlate(); // 'ABC1D23' (Mercosul, the default) +generateLicensePlate('LLLNNNN'); // 'ABC1234' +generateLicensePlate('LLLNNLN'); // 'ABC1D23' (a format outside the two in circulation falls back to the default) ``` -### parsePis +A `format` outside the two supported literals falls back to the Mercosul default, the way every other generator in this package treats an option it does not know, so the result is always a plate `isValidLicensePlate` accepts. That default sequence is `LLLNLNN`, from Resolução CONTRAN nº 969/2022, Anexo I item 1.2, the single sequence the resolution defines for every vehicle, motorcycles included. (2.3.0 used an unknown string verbatim, so `generateLicensePlate('LLLNNLN')` produced the withdrawn motorcycle sequence and `generateLicensePlate('bogus')` five digits; neither is a plate.) -Remove PIS formatting, keep only digits, and cap the result to 11 digits. +#### getFormatLicensePlate + +Detect the normalized format of a license plate. ```javascript -import { parsePis } from '@brazilian-utils/brazilian-utils'; +import { getFormatLicensePlate } from '@brazilian-utils/brazilian-utils'; -parsePis('123.45678.90-1'); // 12345678901 +getFormatLicensePlate('ABC-1234'); // 'LLLNNNN' +getFormatLicensePlate('ABC1D23'); // 'LLLNLNN' +getFormatLicensePlate('ABC12D3'); // null (not a Mercosul sequence) +getFormatLicensePlate('INVALID'); // null +getFormatLicensePlate('ABC1234EXTRA'); // null (too many characters) ``` -### formatCep +`getFormatLicensePlate` exports the `LicensePlateFormat` type (`"LLLNNNN" | "LLLNLNN"`); `generateLicensePlate` re-exports it as `GenerateLicensePlateFormat`. + +#### convertLicensePlateToMercosul -Format CEP ([brazilian postal code](https://en.wikipedia.org/wiki/C%C3%B3digo_de_Endere%C3%A7amento_Postal)). +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`). Returns `""` when the value is not a valid old format license plate. ```javascript -import { formatCep } from '@brazilian-utils/brazilian-utils'; +import { convertLicensePlateToMercosul } from '@brazilian-utils/brazilian-utils'; -formatCep('92500000'); // 92500-000 +convertLicensePlateToMercosul('ABC1234'); // 'ABC1C34' +convertLicensePlateToMercosul('abc-1234'); // 'ABC1C34' +convertLicensePlateToMercosul('ABC1D23'); // '' (already Mercosul) ``` -### parseCep +### RENAVAM -Remove CEP formatting, keep only digits, and cap the result to 8 digits. +#### 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). 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 { parseCep } from '@brazilian-utils/brazilian-utils'; +import { isValidRenavam } from '@brazilian-utils/brazilian-utils'; -parseCep('92500-000'); // 92500000 +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) ``` -### getAddressInfoByCep +#### generateRenavam -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`. +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 { getAddressInfoByCep } from '@brazilian-utils/brazilian-utils'; +import { generateRenavam } from '@brazilian-utils/brazilian-utils'; -// Using the default providers (['viacep', 'brasilapi']) -const address = await getAddressInfoByCep('01310100'); -// { cep: '01310100', state: 'SP', city: 'São Paulo', neighborhood: 'Bela Vista', street: 'Avenida Paulista' } +generateRenavam(); // '12345678900' +``` -// Using specific providers -const address = await getAddressInfoByCep('01310-100', { - providers: ['viacep', 'brasilapi'] -}); +### PIS -// Using number input (will be padded automatically) -const address = await getAddressInfoByCep(1310100); +#### isValidPis + +Check if PIS is valid. Accepts the usual mask characters (`.`, `-`, `/`, `(`, `)`, `,`, `*`) and whitespace. + +```javascript +import { isValidPis } from '@brazilian-utils/brazilian-utils'; + +isValidPis('12056412547'); // false +``` + +#### formatPis + +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'; + +formatPis('12345678901'); // 123.45678.90-1 +formatPis('123456789', { pad: true }); // 001.23456.78-9 +``` + +#### parsePis + +Remove PIS formatting, keep only digits, and cap the result to 11 digits. + +```javascript +import { parsePis } from '@brazilian-utils/brazilian-utils'; + +parsePis('123.45678.90-1'); // 12345678901 +``` + +#### generatePis + +Generate a valid random PIS. Uses `Math.random()` internally, so it is not cryptographically secure. + +```javascript +import { generatePis } from '@brazilian-utils/brazilian-utils'; + +generatePis(); // '91077906857' ``` -### isValidProcessoJuridico +### Processo jurídico + +#### 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 +#### 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 +#### parseProcessoJuridico Remove processo jurídico formatting, keep only digits, and cap the result to 20 digits. Both the current CNJ mask (`NNNNNNN-DD.AAAA.J.TR.OOOO`) and the older one are accepted, since only the digits are kept. @@ -802,18 +1014,22 @@ import { parseProcessoJuridico } from '@brazilian-utils/brazilian-utils'; parseProcessoJuridico('0002080-25.2012.5.15.0049'); // 00020802520125150049 ``` -### isValidIe +#### generateProcessoJuridico -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`). +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 { isValidIe } from '@brazilian-utils/brazilian-utils'; +import { generateProcessoJuridico } from '@brazilian-utils/brazilian-utils'; -isValidIe('AC', '0187634580933'); // false -isValidIe('go', '109161793'); // true (case-insensitive) +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) ``` -### isValidBankAccount +### Bank accounts and banks + +#### isValidBankAccount Check if a Brazilian bank account is valid. The `bankCode` must belong to the Banco Central do Brasil STR participants list (the same dataset used by `getBankByCode`), so an unassigned code such as `'999'` is always invalid. Banks are then validated in one of three ways: by their published check digit algorithm, by structure only (bank exists and the agency/account match the documented digit lengths, for banks that publish no check digit rule) or by a generic mod10/mod11 check, which stays the fallback for every other listed bank. @@ -821,11 +1037,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 +1051,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. @@ -918,7 +1132,7 @@ isValidBankAccount({ }); // true (Banco ABC Brasil, generic mod10 fallback) ``` -### getBanks +#### getBanks Get every Brazilian bank with a compensation code (COMPE), published by Banco Central do Brasil in the [STR participants list](https://www.bcb.gov.br/content/estabilidadefinanceira/str1/ParticipantesSTR.csv). Each bank (typed as `Bank`) has a `code` (COMPE, 3 digits), an `ispb` (Identificador do Sistema de Pagamentos Brasileiro, 8 digits) and a `name`. Each call returns a fresh array of fresh objects, so mutating the result never affects subsequent calls. @@ -934,7 +1148,7 @@ getBanks(); // ] ``` -### getBankByCode +#### getBankByCode Look a Brazilian bank up by its compensation code (COMPE), published by Banco Central do Brasil in the [STR participants list](https://www.bcb.gov.br/content/estabilidadefinanceira/str1/ParticipantesSTR.csv). 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 code. @@ -946,9 +1160,9 @@ getBankByCode(1); // { code: '001', ispb: '00000000', name: 'Banco do Brasil S.A getBankByCode('999'); // null ``` -### getBankByIspb +#### 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'; @@ -958,23 +1172,26 @@ getBankByIspb('60701190'); // { code: '341', ispb: '60701190', name: 'ITAÚ UNIB getBankByIspb('99999999'); // null ``` -### isValidIban +### IBAN + +#### 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 +#### 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 +1199,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 +#### 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,45 +1231,15 @@ parseIban('BR1500000000000010932840814P2'); // owner: '2' // } -parseIban('DE89370400440532013000'); // null (non Brazilian IBAN) -parseIban('BR1500000000000010932840814P-2'); // null (hyphens are not part of an IBAN) -``` - -### 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. - -```javascript -import { isValidCreditCard } from '@brazilian-utils/brazilian-utils'; - -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('4111111111111112'); // false (bad check digit) -isValidCreditCard(4111111111111111111); // false (above 2^53 - 1, pass it as a string) +getIbanInfo('DE89370400440532013000'); // null (non Brazilian IBAN) +getIbanInfo('BR15 000 00000 0000 1093 2840 814P 2'); // null (a separator inside a group) ``` -### 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`. - -```javascript -import { capitalize } from '@brazilian-utils/brazilian-utils'; - -capitalize('josé e maria'); // José e Maria -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(' josé maria '); // José Maria (every run of whitespace, tabs and newlines included, collapses into one space) -``` +### Currency, numbers and dates in words -### formatCurrency +#### 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'; @@ -1057,9 +1255,9 @@ formatCurrency('-10.5'); // -10,50 (a leading "-" is preserved) formatCurrency(Number.NaN); // "" (non finite numbers format as an empty string) ``` -### parseCurrency +#### 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'; @@ -1075,9 +1273,9 @@ parseCurrency('R$ 1,001', { precision: 3 }); // 1.001 parseCurrency(''); // 0 ``` -### convertNumberToWords +#### 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,28 +1285,48 @@ 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 +#### 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 +#### 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. 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'; + +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('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" +convertDateToWords('10/05/1999'); // "dez de maio de mil novecentos e noventa e nove" +convertDateToWords('31/04/2024'); // "" (April has 30 days) +convertDateToWords('invalid'); // "" +convertDateToWords('29/02/1900'); // "" (1900 is not a leap year) +``` + +### States and municipalities + +#### 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`). The list is sorted by name with `localeCompare` in the "pt-BR" locale, so accented names land where a Brazilian reader expects them: Pará, Paraíba, Paraná and Rio de Janeiro, Rio Grande do Norte, Rio Grande do Sul. Each call returns a fresh array of fresh objects, so mutating the result never affects subsequent calls. Exports the `State`, `StateCode` and `StateName` types. `State` is a discriminated union with one member per state, so the fields of a state are tied to each other: narrowing a `State` by `code` narrows its `name`, `regionCode`, `regionName` and `ibgeCode` too (`Extract['name']` is `'São Paulo'`), and an impossible combination such as `{ code: 'SP', name: 'Acre' }` is not a `State`. @@ -1147,9 +1365,9 @@ getStates(); // ] ``` -### getStateByIbgeCode +#### 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'; @@ -1165,9 +1383,9 @@ getStateByIbgeCode(-35); // null getStateByIbgeCode(3.5); // null ``` -### getStateCodeByName +#### 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'; @@ -1178,7 +1396,7 @@ getStateCodeByName(' Rio de Janeiro '); // 'RJ' getStateCodeByName('Neverland'); // null ``` -### getStateNameByCode +#### getStateNameByCode Get the full name of a Brazilian state given its two-letter code (sigla). The match is case-insensitive and ignores leading/trailing whitespace, so `'sp'`, `'SP'` and `' Sp '` all resolve to `'São Paulo'`. Exports the `StateName` type. @@ -1191,7 +1409,7 @@ getStateNameByCode(' Rj '); // 'Rio de Janeiro' getStateNameByCode('ZZ'); // null ``` -### getTimezoneByState +#### getTimezoneByState Get the IANA time zone database name (tzdata zone) for a Brazilian state, chosen as the zone of the state capital. The match is case-insensitive and ignores leading/trailing whitespace. Some tzdata zones cover more than one state: `America/Sao_Paulo` also covers DF, GO, MG, ES, RJ, PR, SC and RS besides SP, and `America/Fortaleza` also covers MA, PI, RN and PB besides CE. Pernambuco resolves to `America/Recife`, not `America/Noronha`: Fernando de Noronha is an archipelago district of PE, not a state of its own. @@ -1205,9 +1423,60 @@ getTimezoneByState('PE'); // 'America/Recife' getTimezoneByState('ZZ'); // null ``` -### getCities +#### 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. 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'; + +// Return every Brazilian municipality (sorted by name). +getMunicipalities(); +// [ +// { code: '5200050', name: 'Abadia de Goiás', stateCode: 'GO' }, +// { code: '3100104', name: 'Abadia dos Dourados', stateCode: 'MG' }, +// { code: '5200100', name: 'Abadiânia', stateCode: 'GO' }, +// { code: '3100203', name: 'Abaeté', stateCode: 'MG' }, +// { code: '1500107', name: 'Abaetetuba', stateCode: 'PA' }, +// ... 5566 more items +// ] + +// Return every municipality of the São Paulo state. +getMunicipalities('SP'); +// [ +// { code: '3500105', name: 'Adamantina', stateCode: 'SP' }, +// { code: '3500204', name: 'Adolfo', stateCode: 'SP' }, +// { code: '3500303', name: 'Aguaí', stateCode: 'SP' }, +// { code: '3500402', name: 'Águas da Prata', stateCode: 'SP' }, +// { code: '3500501', name: 'Águas de Lindóia', stateCode: 'SP' }, +// ... 640 more items +// ] + +getMunicipalities('ZZ'); // [] +``` + +`getMunicipalities` embeds all 5571 IBGE municipalities and their codes, so it carries the same bundle-size cost as `getCities`. See [Bundle size](getting-started.md#bundle-size) for how to lazy-load it via `@brazilian-utils/brazilian-utils/get-municipalities` instead of the root import. + +#### getMunicipalityByCode + +Look up a Brazilian municipality by its 7-digit IBGE code. Accepts the code as a string or a number, with any non-digit characters stripped before matching; a code given as a number must be a non-negative integer, so `-3550308` and `355030.8` return `null` instead of being read as `3550308`. Returns `{ code, name, stateCode }`, a fresh object, or `null` when the code is not 7 digits long or does not match any known municipality. + +```javascript +import { getMunicipalityByCode } from '@brazilian-utils/brazilian-utils'; + +getMunicipalityByCode('3550308'); +// { code: '3550308', name: 'São Paulo', stateCode: 'SP' } + +getMunicipalityByCode(3550308); +// { code: '3550308', name: 'São Paulo', stateCode: 'SP' } + +getMunicipalityByCode('0000000'); // null (unknown code) +getMunicipalityByCode('123'); // null (not 7 digits) +``` + +#### 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 +1514,70 @@ 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. + +#### 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`. 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'; + +await getMunicipality({ code: '3550308' }); +// ['São Paulo', 'SP'] + +await getMunicipality({ code: 3550308 }); +// ['São Paulo', 'SP'] + +await getMunicipality({ municipalityName: 'sao paulo', uf: 'sp' }); +// '3550308' + +await getMunicipality({ code: '0000000' }); +// null (unknown code) + +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 `GetMunicipalityParams`) resolves to the union of both. The 2.3.0 names `GetMunicipalityOptions`, `GetMunicipalityByCodeOptions` and `GetMunicipalityByNameOptions` are still exported as deprecated aliases of these. + +```typescript +import { + getMunicipality, + type GetMunicipalityByCodeParams, + type GetMunicipalityByNameParams, + type GetMunicipalityParams, +} from '@brazilian-utils/brazilian-utils'; + +const byCode: GetMunicipalityByCodeParams = { code: '3550308' }; +const byName: GetMunicipalityByNameParams = { municipalityName: 'sao paulo', uf: 'sp' }; + +await getMunicipality(byCode); +// Promise<[string, string] | null> + +await getMunicipality(byName); +// Promise + +const lookUp = (options: GetMunicipalityParams) => getMunicipality(options); +// (options: GetMunicipalityParams) => Promise<[string, string] | string | null> +``` + +### Holidays and business days + +#### getHolidays -### 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, several states still carry a state-level entry of their own on the same date, under the same `"Dia da Consciência Negra"` name in MT, RJ, AM and SP, and under `"Dia Estadual da Consciência Negra"` in AP, the name that state's own law uses. Commemorative dates that no law turns into a holiday are not listed: RN's "Dia do Rio Grande do Norte" (7 August, Lei RN nº 7.831/2000) is one, and neither is RO's "Dia dos Evangélicos" (18 June), whose law the STF struck down in ADI 3940. 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 `[]`. -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. +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'; @@ -1270,171 +1598,203 @@ getHolidays({ year: 2024, stateCode: 'SP' }); // Includes national holidays plus state-specific holidays (e.g., "Revolução Constitucionalista") ``` -### isValidPassport +#### isHoliday -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 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 { isValidPassport } from '@brazilian-utils/brazilian-utils'; +import { isHoliday } from '@brazilian-utils/brazilian-utils'; -isValidPassport('AB123456'); // true -isValidPassport('ab123456'); // true (case-insensitive) -isValidPassport('AB-123.456'); // true (symbols are ignored) -isValidPassport('12345678'); // false +isHoliday({ targetDate: new Date(2024, 0, 1) }); // true +isHoliday({ targetDate: new Date(2024, 6, 9), stateCode: 'SP' }); // true +isHoliday(); // false ``` -### formatPassport +#### isBusinessDay -Format a Brazilian passport number (uppercase, without symbols, capped to 8 characters). A non-string input returns an empty string. +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 { formatPassport } from '@brazilian-utils/brazilian-utils'; +import { isBusinessDay } from '@brazilian-utils/brazilian-utils'; -formatPassport('ab123456'); // 'AB123456' -formatPassport('AB-123.456'); // 'AB123456' +isBusinessDay(new Date(2024, 0, 2)); // true (Tuesday, not a holiday) +isBusinessDay(new Date(2024, 0, 1)); // false (Ano novo) +isBusinessDay(new Date(2024, 0, 6)); // false (Saturday) +isBusinessDay(new Date(2024, 1, 13)); // false (Carnaval, optional holiday, counts by default) +isBusinessDay(new Date(2024, 1, 13), { includeOptional: false }); // true +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 ``` -### generatePassport +#### addBusinessDays -Generate a random valid Brazilian passport number. +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 { generatePassport } from '@brazilian-utils/brazilian-utils'; +import { addBusinessDays } from '@brazilian-utils/brazilian-utils'; -generatePassport(); // 'RY393097' +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) ``` -### parsePassport +#### subBusinessDays -Remove all non-alphanumeric characters from a passport number, uppercase the result, and cap it to 8 characters. A non-string input returns an empty string. +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 { parsePassport } from '@brazilian-utils/brazilian-utils'; +import { subBusinessDays } from '@brazilian-utils/brazilian-utils'; -parsePassport('AB-123.456'); // 'AB123456' -parsePassport(' AB 123 456 '); // 'AB123456' +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) ``` -### generateCep +#### differenceInBusinessDays -Generate a random CEP. +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 { generateCep } from '@brazilian-utils/brazilian-utils'; +import { differenceInBusinessDays } from '@brazilian-utils/brazilian-utils'; -generateCep(); // '92500000' +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 ``` -### formatCnh +### Passport -Format CNH. +#### 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. 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 { formatCnh } from '@brazilian-utils/brazilian-utils'; +import { isValidPassport } from '@brazilian-utils/brazilian-utils'; -formatCnh('02650306461'); // 026503064-61 -formatCnh('2650306461', { pad: true }); // 026503064-61 +isValidPassport('AB123456'); // true +isValidPassport('ab123456'); // true (case-insensitive) +isValidPassport('AB-123.456'); // true (symbols are ignored) +isValidPassport('12345678'); // false ``` -### isValidCnh +#### formatPassport -Check if CNH is valid. +Format a Brazilian passport number (uppercase, without symbols, capped to 8 characters). A non-string input returns an empty string. ```javascript -import { isValidCnh } from '@brazilian-utils/brazilian-utils'; +import { formatPassport } from '@brazilian-utils/brazilian-utils'; -isValidCnh('00000000119'); // true +formatPassport('ab123456'); // 'AB123456' +formatPassport('AB-123.456'); // 'AB123456' ``` -### generateCnh +#### parsePassport -Generate a valid random CNH. +Remove all non-alphanumeric characters from a passport number, uppercase the result, and cap it to 8 characters. A non-string input returns an empty string. ```javascript -import { generateCnh } from '@brazilian-utils/brazilian-utils'; +import { parsePassport } from '@brazilian-utils/brazilian-utils'; -generateCnh(); // '02650306461' +parsePassport('AB-123.456'); // 'AB123456' +parsePassport(' AB 123 456 '); // 'AB123456' ``` -### parseCnh +#### generatePassport -Remove CNH formatting, keep only digits, and cap the result to 11 digits. +Generate a random valid Brazilian passport number. Uses `Math.random()` internally, so it is not cryptographically secure. ```javascript -import { parseCnh } from '@brazilian-utils/brazilian-utils'; +import { generatePassport } from '@brazilian-utils/brazilian-utils'; -parseCnh('026503064-61'); // '02650306461' +generatePassport(); // 'RY393097' ``` -### getCepInfoByAddress +### CNH + +#### isValidCnh -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. +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 { getCepInfoByAddress } from '@brazilian-utils/brazilian-utils'; +import { isValidCnh } from '@brazilian-utils/brazilian-utils'; -const ceps = await getCepInfoByAddress({ - federalUnit: 'SP', - city: 'Sao Paulo', - street: 'Avenida Paulista' -}); +isValidCnh('00000000119'); // true +isValidCnh('000000001-19'); // true (hyphen before the check digits) +isValidCnh('ab00000000119'); // false (letters are rejected) +``` -// [ -// { -// cep: '01310100', -// logradouro: 'Avenida Paulista', -// complemento: 'lado par', -// bairro: 'Bela Vista', -// localidade: 'São Paulo', -// uf: 'SP' -// } -// ] +#### formatCnh + +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'; + +formatCnh('02650306461'); // 026503064-61 +formatCnh('2650306461', { pad: true }); // 026503064-61 ``` -### generateProcessoJuridico +#### parseCnh -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. +Remove CNH formatting, keep only digits, and cap the result to 11 digits. Returns `''` when there is no digit at all. ```javascript -import { generateProcessoJuridico } from '@brazilian-utils/brazilian-utils'; +import { parseCnh } from '@brazilian-utils/brazilian-utils'; -generateProcessoJuridico(); // '89478643020269670326' -generateProcessoJuridico({ year: 2026, court: 5 }); // string | null -generateProcessoJuridico({ year: 10000 }); // null (year out of range) +parseCnh('026503064-61'); // '02650306461' ``` -### formatLegalNature +#### generateCnh -Format a legal nature code. +Generate a valid random CNH. Uses `Math.random()` internally, so it is not cryptographically secure. ```javascript -import { formatLegalNature } from '@brazilian-utils/brazilian-utils'; +import { generateCnh } from '@brazilian-utils/brazilian-utils'; -formatLegalNature('2062'); // 206-2 +generateCnh(); // '02650306461' ``` -### isValidLegalNature +### Legal nature + +#### isValidLegalNature -Check if a legal nature code exists in the official list. The table follows IBGE/CONCLA's "Natureza Jurídica 2021": 92 official codes plus 8 legacy codes kept for backwards compatibility. Only the usual mask characters (hyphens, dots, whitespace) are tolerated around the 4 digits, so `'2062a'` is rejected instead of being read as `'2062'`. +Check if a legal nature code exists in the official list. The table follows IBGE/CONCLA's "Natureza Jurídica 2021": the 92 codes in force plus the 8 a past revision of the table retired, kept because they still appear in records filed while they were in force. Use `getLegalNature` to tell the two apart: a retired code comes back with `legacy: true` and the `currentCode` it corresponds to today. Only the usual mask characters (hyphens, dots, whitespace) are tolerated around the 4 digits, so `'2062a'` is rejected instead of being read as `'2062'`. ```javascript import { isValidLegalNature } from '@brazilian-utils/brazilian-utils'; isValidLegalNature('2062'); // true +isValidLegalNature('2208'); // true (retired by a past revision, still accepted) isValidLegalNature('9999'); // false ``` -### generateLegalNature +#### formatLegalNature -Generate a random valid 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 { generateLegalNature } from '@brazilian-utils/brazilian-utils'; +import { formatLegalNature } from '@brazilian-utils/brazilian-utils'; -generateLegalNature(); // '2062' +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) ``` -### parseLegalNature +#### parseLegalNature Remove legal nature formatting, keep only digits, and cap the result to 4 digits. @@ -1444,269 +1804,109 @@ import { parseLegalNature } from '@brazilian-utils/brazilian-utils'; parseLegalNature('206-2'); // '2062' ``` -### getLegalNatures +#### generateLegalNature -Get the legal nature map keyed by code. +Generate a random valid legal nature code. Only the 92 codes in force are drawn, never one of the 8 a past revision retired. Uses `Math.random()` internally, so it is not cryptographically secure. ```javascript -import { getLegalNatures } from '@brazilian-utils/brazilian-utils'; - -const legalNatures = getLegalNatures(); +import { generateLegalNature } from '@brazilian-utils/brazilian-utils'; -legalNatures['2062']; // 'Sociedade Empresária Limitada' +generateLegalNature(); // '2062' ``` -### getLegalNature +#### 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. + +A code a past revision of the table retired is still looked up, because it keeps appearing in records filed while it was in force, and comes back with `legacy: true` and the `currentCode` it corresponds to today, per the CONCLA correspondence spreadsheets. The 92 codes in force have `legacy: false` and no `currentCode`. + +| Retired code | Description | Corresponds to | +| --- | --- | --- | +| `2076` | Sociedade Empresária em Nome Coletivo | `2070`, the code the 2003.1 revision renumbered it to, same denomination | +| `2100` | Sociedade Mercantil de Capital e Indústria | none, marked "categoria extinta" by the 2003.1 x 2009 correspondence | +| `2208` | Entidade Binacional Itaipu | `2275` Empresa Binacional | +| `3042` | Organização Social | `3069` Fundação Privada; the 2014 revision later created `3301` Organização Social (OS), where an entity qualified as one is classified today | +| `3050` | Organização da Sociedade Civil de Interesse Público (Oscip) | none, an Oscip is classified by the form it takes (`3999` or `3069`) | +| `3093` | Unidade Executora (Programa Dinheiro Direto na Escola) | `3999` Associação Privada | +| `3123` | Partido Político | none, the 2014 revision split it into `3255`, `3263` and `3271` | +| `5002` | Organização Internacional e Outras Instituições Extraterritoriais | `5010` Organização Internacional, the code it was opened into alongside `5029` and `5037` | ```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' }, +// legacy: false, +// } +getLegalNature('2208'); +// { +// code: '2208', +// description: 'Entidade Binacional Itaipu', +// category: { code: '2', description: 'Entidades Empresariais' }, +// legacy: true, +// currentCode: '2275', +// } +getLegalNature('3123')?.currentCode; // null (retired without a successor) +getLegalNature('206-2')?.code; // '2062' +getLegalNature(206.2)?.category.description; // 'Entidades Empresariais' getLegalNature('0000'); // null ``` -### generatePhone +#### getLegalNatures -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. +Get the legal nature map keyed by code. Only the 92 codes of the CONCLA 2021 table, the ones in force, are listed by default; pass `{ includeLegacy: true }` (`GetLegalNaturesParams`) to add the 8 a past revision of the table retired. ```javascript -import { generatePhone } from '@brazilian-utils/brazilian-utils'; +import { getLegalNatures } from '@brazilian-utils/brazilian-utils'; -generatePhone(); // '11912345678' or '1131234567' -generatePhone('mobile'); // '11912345678' -generatePhone('landline'); // '1131234567' -generatePhone('service'); // '08001234567' or '40041234' +const legalNatures = getLegalNatures(); + +legalNatures['2062']; // 'Sociedade Empresária Limitada' +Object.keys(legalNatures).length; // 92 +legalNatures['2208']; // undefined (retired by a past revision) +getLegalNatures({ includeLegacy: true })['2208']; // 'Entidade Binacional Itaipu' ``` -### formatLicensePlate +#### getLegalNaturesByCategory -Format a license plate. Old Brazilian plates (`LLLNNNN`) are returned with a hyphen and Mercosul plates (`LLLNLNN`) stay normalized. +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 `[]`. Only the codes in force are listed by default; pass `{ includeLegacy: true }` (`GetLegalNaturesByCategoryOptions`) to add the retired codes of the category, in code order. ```javascript -import { formatLicensePlate } from '@brazilian-utils/brazilian-utils'; +import { getLegalNaturesByCategory } from '@brazilian-utils/brazilian-utils'; -formatLicensePlate('abc1234'); // 'ABC-1234' -formatLicensePlate('abc1d23'); // 'ABC1D23' +getLegalNaturesByCategory('4')[0]; +// { +// code: '4014', +// description: 'Empresa Individual Imobiliária', +// category: { code: '4', description: 'Pessoas Físicas' }, +// legacy: false, +// } +getLegalNaturesByCategory(4).length; // 6 +getLegalNaturesByCategory('2').length; // 30 +getLegalNaturesByCategory('2', { includeLegacy: true }).length; // 33 +getLegalNaturesByCategory('9'); // [] ``` -### generateLicensePlate +### Voter ID + +#### isValidVoterId -Generate a random license plate in the chosen format. +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 { generateLicensePlate } from '@brazilian-utils/brazilian-utils'; +import { generateVoterId, isValidVoterId } from '@brazilian-utils/brazilian-utils'; -generateLicensePlate(); // 'ABC1D23' (Mercosul, the default) -generateLicensePlate('LLLNNNN'); // 'ABC1234' +const voterId = generateVoterId('SP'); + +isValidVoterId(voterId); // true ``` -### getFormatLicensePlate +#### formatVoterId -Detect the normalized format of a license plate. - -```javascript -import { getFormatLicensePlate } from '@brazilian-utils/brazilian-utils'; - -getFormatLicensePlate('ABC-1234'); // 'LLLNNNN' -getFormatLicensePlate('ABC1D23'); // 'LLLNLNN' -getFormatLicensePlate('ABC12D3'); // null (not a Mercosul sequence) -getFormatLicensePlate('INVALID'); // null -getFormatLicensePlate('ABC1234EXTRA'); // null (too many characters) -``` - -`getFormatLicensePlate` exports the `LicensePlateFormat` type (`"LLLNNNN" | "LLLNLNN"`); `generateLicensePlate` re-exports it as `GenerateLicensePlateFormat`. - -### parseLicensePlate - -Remove separators from a license plate, normalize it to uppercase, and cap it to 7 characters. - -```javascript -import { parseLicensePlate } from '@brazilian-utils/brazilian-utils'; - -parseLicensePlate('abc-1234'); // 'ABC1234' -``` - -### 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`). Returns `""` when the value is not a valid old format license plate. - -```javascript -import { convertLicensePlateToMercosul } from '@brazilian-utils/brazilian-utils'; - -convertLicensePlateToMercosul('ABC1234'); // 'ABC1C34' -convertLicensePlateToMercosul('abc-1234'); // 'ABC1C34' -convertLicensePlateToMercosul('ABC1D23'); // '' (already Mercosul) -``` - -### generatePis - -Generate a valid random PIS. - -```javascript -import { generatePis } from '@brazilian-utils/brazilian-utils'; - -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`. - -```javascript -import { getMunicipality } from '@brazilian-utils/brazilian-utils'; - -await getMunicipality({ code: '3550308' }); -// ['São Paulo', 'SP'] - -await getMunicipality({ code: 3550308 }); -// ['São Paulo', 'SP'] - -await getMunicipality({ municipalityName: 'sao paulo', uf: 'sp' }); -// '3550308' - -await getMunicipality({ code: '0000000' }); -// null (unknown code) - -await getMunicipality({ code: '123' }); -// null (not 7 digits) -``` - -### 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. - -```javascript -import { getMunicipalities } from '@brazilian-utils/brazilian-utils'; - -// Return every Brazilian municipality (sorted by name). -getMunicipalities(); -// [ -// { code: '5200050', name: 'Abadia de Goiás', stateCode: 'GO' }, -// { code: '3100104', name: 'Abadia dos Dourados', stateCode: 'MG' }, -// { code: '5200100', name: 'Abadiânia', stateCode: 'GO' }, -// { code: '3100203', name: 'Abaeté', stateCode: 'MG' }, -// { code: '1500107', name: 'Abaetetuba', stateCode: 'PA' }, -// ... 5566 more items -// ] - -// Return every municipality of the São Paulo state. -getMunicipalities('SP'); -// [ -// { code: '3500105', name: 'Adamantina', stateCode: 'SP' }, -// { code: '3500204', name: 'Adolfo', stateCode: 'SP' }, -// { code: '3500303', name: 'Aguaí', stateCode: 'SP' }, -// { code: '3500402', name: 'Águas da Prata', stateCode: 'SP' }, -// { code: '3500501', name: 'Águas de Lindóia', stateCode: 'SP' }, -// ... 640 more items -// ] - -getMunicipalities('ZZ'); // [] -``` - -`getMunicipalities` embeds all 5571 IBGE municipalities and their codes, so it carries the same bundle-size cost as `getCities`. See [Bundle size](getting-started.md#bundle-size) for how to lazy-load it via `@brazilian-utils/brazilian-utils/get-municipalities` instead of the root import. - -### getMunicipalityByCode - -Look up a Brazilian municipality by its 7-digit IBGE code. Accepts the code as a string or a number, with any non-digit characters stripped before matching; a code given as a number must be a non-negative integer, so `-3550308` and `355030.8` return `null` instead of being read as `3550308`. Returns `{ code, name, stateCode }`, a fresh object, or `null` when the code is not 7 digits long or does not match any known municipality. - -```javascript -import { getMunicipalityByCode } from '@brazilian-utils/brazilian-utils'; - -getMunicipalityByCode('3550308'); -// { code: '3550308', name: 'São Paulo', stateCode: 'SP' } - -getMunicipalityByCode(3550308); -// { code: '3550308', name: 'São Paulo', stateCode: 'SP' } - -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`. - -```javascript -import { isHoliday } from '@brazilian-utils/brazilian-utils'; - -isHoliday({ targetDate: new Date(2024, 0, 1) }); // true -isHoliday({ targetDate: new Date(2024, 6, 9), stateCode: 'SP' }); // true -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`. - -```javascript -import { isBusinessDay } from '@brazilian-utils/brazilian-utils'; - -isBusinessDay(new Date(2024, 0, 2)); // true (Tuesday, not a holiday) -isBusinessDay(new Date(2024, 0, 1)); // false (Ano novo) -isBusinessDay(new Date(2024, 0, 6)); // false (Saturday) -isBusinessDay(new Date(2024, 1, 13)); // false (Carnaval, optional holiday, counts by default) -isBusinessDay(new Date(2024, 1, 13), { includeOptional: false }); // true -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 -``` - -### 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`. - -```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) -``` - -### 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`. - -```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 -``` - -### 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. - -```javascript -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" -convertDateToWords('10/05/1999'); // "dez de maio de mil novecentos e noventa e nove" -convertDateToWords('31/04/2024'); // "" (April has 30 days) -convertDateToWords('invalid'); // "" -convertDateToWords('29/02/1900'); // "" (1900 is not a leap year) -``` - -### formatVoterId - -Format a voter ID number. Uses the 12-digit grouping `0000 0000 00 00` by default; the 13-digit grouping `0000 0000 0 00 00` is used only when the sanitized value has more than 12 digits **and** its federative union code (the 10th and 11th digits) is `01` (São Paulo) or `02` (Minas Gerais), the two states whose voter ids may carry a 9-digit sequential number. +Format a voter ID number. Uses the 12-digit grouping `0000 0000 00 00` by default; the 13-digit grouping `0000 0000 0 00 00` is used only when the sanitized value has more than 12 digits **and** its federative union code (the 10th and 11th digits) is `01` (São Paulo) or `02` (Minas Gerais), the two states whose voter ids may carry a 9-digit sequential number. ```javascript import { formatVoterId } from '@brazilian-utils/brazilian-utils'; @@ -1715,19 +1915,18 @@ formatVoterId('123456780175'); // '1234 5678 01 75' formatVoterId('1234567880191'); // '1234 5678 8 01 91' (13-digit SP/MG voter id) ``` -### isValidVoterId +#### parseVoterId -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`). +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). ```javascript -import { generateVoterId, isValidVoterId } from '@brazilian-utils/brazilian-utils'; - -const voterId = generateVoterId('SP'); +import { parseVoterId } from '@brazilian-utils/brazilian-utils'; -isValidVoterId(voterId); // true +parseVoterId('1234 5678 01 75'); // '123456780175' +parseVoterId('1234 5678 8 01 91'); // '1234567880191' (13-digit SP/MG voter id) ``` -### generateVoterId +#### generateVoterId Generate a valid random voter ID number. You can optionally provide a state code; an unknown state code falls back to `"ZZ"` (issued abroad) instead of throwing. Uses `Math.random()` internally, so it is not cryptographically secure. @@ -1739,47 +1938,53 @@ generateVoterId('SP'); // valid random voter ID for Sao Paulo generateVoterId('XX'); // falls back to "ZZ" instead of throwing ``` -### parseVoterId +### CNS -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). +#### isValidCns -```javascript -import { parseVoterId } from '@brazilian-utils/brazilian-utils'; - -parseVoterId('1234 5678 01 75'); // '123456780175' -parseVoterId('1234 5678 8 01 91'); // '1234567880191' (13-digit SP/MG voter id) -``` +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. -### 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. +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 +#### 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' ``` -### isValidCertidao +#### 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' +``` + +### Certidão + +#### 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 +1998,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 ``` -### parseCertidao +#### formatCertidao -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. +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 + +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 +2043,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 +2061,11 @@ 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 +### CEI, CNO and CAEPF -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. +#### isValidCei -```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'; @@ -1858,9 +2077,9 @@ isValidCei('24.985.96743/68'); // false (invalid check digit) isValidCei('000000000000'); // false (repeated digits) ``` -### formatCei +#### 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 ``` -### isValidCno +#### parseCei -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. +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 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'; @@ -1884,9 +2113,9 @@ isValidCno('110840168063'); // false (invalid check digit) isValidCno('000000000000'); // false (repeated digits) ``` -### formatCno +#### 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 ``` -### isValidCaepf +#### 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 +#### 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,35 +2162,21 @@ formatCaepf(41142260000101); // 411.422.600/001-01 formatCaepf('184', { pad: true }); // 000.000.000/001-84 ``` -### isValidRegistroProfissional +#### parseCaepf -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). +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 { isValidRegistroProfissional } from '@brazilian-utils/brazilian-utils'; +import { parseCaepf } 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 +parseCaepf('293.118.610/001-84'); // '29311861000184' ``` -### 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. - -```javascript -import { isValidVin } from '@brazilian-utils/brazilian-utils'; - -isValidVin('1HGCM82633A004352'); // true -isValidVin('1m8gdm9axkp042788'); // true (check digit X, lowercase) -isValidVin('1HGCM82633A004353'); // false (bad check digit) -isValidVin('1HGCM8263IA004352'); // false (contains the excluded letter I) -``` +### Classification codes (CBO, CNAE, NCM, CFOP, CST, CSOSN) -### isValidCbo +#### 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,145 +2184,273 @@ 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 +#### 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 +#### 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) ``` -### formatCnae +#### 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 +#### 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 +#### 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 +#### 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 ``` -### isValidCfop +#### parseNcm -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. +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. 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) ``` -### getCfop +#### parseCfop -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`. +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, 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 +#### isValidCst Check if a CST (Código de Situação Tributária) code is valid for a given tax. Pass the tax through `options.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 +#### 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) +``` + +### Text + +#### 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. 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('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 (case-insensitive match) +capitalize(' josé maria '); // José Maria (every run of whitespace, tabs and newlines included, collapses into one space) ``` -### removeAccents +#### 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. @@ -2109,3 +2463,71 @@ removeAccents('Ceará'); // 'Ceara' removeAccents('Açaí'); // 'Acai' removeAccents(''); // '' ``` + +### 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`). 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. The registration and the state code go together in a single object, typed as `IsValidIeParams`; the 2.3.0 form, `isValidIe(stateCode, ie)`, still works and is deprecated. + +```javascript +import { isValidIe } from '@brazilian-utils/brazilian-utils'; + +isValidIe({ value: '0187634580933', stateCode: 'AC' }); // false +isValidIe({ value: '109161793', stateCode: 'go' }); // true (case-insensitive) +``` + +### isValidEmail + +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'; + +isValidEmail('john.doe@hotmail.com'); // true +``` + +### 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 (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'; + +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) +``` + +### isValidRegistroProfissional + +Check the structure of a professional council registration number (registro/inscrição profissional). It takes a single object, typed as `IsValidRegistroProfissionalParams`, 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({ 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º 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'; + +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) +``` diff --git a/docs/llms.txt b/docs/llms.txt index c0834e7ce..763684371 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`, `getBankByCode` and `getBankByIspb`): ```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,21 +29,18 @@ 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. -- [isValidIe](https://brazilian-utils.com.br/utilities.md#isvalidie): Check if inscrição estadual (state registration) is valid. +- [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. - [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. -- [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). +- [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. - [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. - [isValidLegalNature](https://brazilian-utils.com.br/utilities.md#isvalidlegalnature): Check if a legal nature code exists in the official list. @@ -53,31 +50,34 @@ const { getCities } = await import('@brazilian-utils/brazilian-utils/get-cities' - [isValidCei](https://brazilian-utils.com.br/utilities.md#isvalidcei): Check if a CEI (Cadastro Específico do INSS) number is valid. - [isValidCno](https://brazilian-utils.com.br/utilities.md#isvalidcno): Check if a CNO (Cadastro Nacional de Obras) number is valid. - [isValidCaepf](https://brazilian-utils.com.br/utilities.md#isvalidcaepf): Check if a CAEPF (Cadastro de Atividade Econômica da Pessoa Física) number is valid. -- [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`. +- [isValidIe](https://brazilian-utils.com.br/utilities.md#isvalidie): Check if inscrição estadual (state registration) is valid. +- [isValidEmail](https://brazilian-utils.com.br/utilities.md#isvalidemail): Check if email is valid. +- [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). +- [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. ## Formatters (format*) - [formatCpf](https://brazilian-utils.com.br/utilities.md#formatcpf): Format CPF. - [formatCnpj](https://brazilian-utils.com.br/utilities.md#formatcnpj): Format CNPJ. +- [formatCep](https://brazilian-utils.com.br/utilities.md#formatcep): Format CEP (brazilian postal code). - [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. +- [formatLicensePlate](https://brazilian-utils.com.br/utilities.md#formatlicenseplate): Format a license plate. - [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. - [formatLegalNature](https://brazilian-utils.com.br/utilities.md#formatlegalnature): Format a legal nature code. -- [formatLicensePlate](https://brazilian-utils.com.br/utilities.md#formatlicenseplate): Format a license plate. - [formatVoterId](https://brazilian-utils.com.br/utilities.md#formatvoterid): Format a voter ID number. - [formatCns](https://brazilian-utils.com.br/utilities.md#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. - [formatCertidao](https://brazilian-utils.com.br/utilities.md#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. @@ -91,77 +91,91 @@ 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. +- [parseCep](https://brazilian-utils.com.br/utilities.md#parsecep): Remove CEP formatting, keep only digits, and cap the result to 8 digits. - [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. +- [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. - [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*) - [generateCpf](https://brazilian-utils.com.br/utilities.md#generatecpf): Generate a valid random CPF. - [generateCnpj](https://brazilian-utils.com.br/utilities.md#generatecnpj): Generate a valid random CNPJ. +- [generateCep](https://brazilian-utils.com.br/utilities.md#generatecep): Generate a random CEP. - [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. -- [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. -- [generateProcessoJuridico](https://brazilian-utils.com.br/utilities.md#generateprocessojuridico): Generate a valid random processo jurídico number according to CNJ's definition. -- [generateLegalNature](https://brazilian-utils.com.br/utilities.md#generatelegalnature): Generate a random valid legal nature code. - [generatePhone](https://brazilian-utils.com.br/utilities.md#generatephone): Generate a random Brazilian phone number. - [generateLicensePlate](https://brazilian-utils.com.br/utilities.md#generatelicenseplate): Generate a random license plate in the chosen format. +- [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. - [generatePis](https://brazilian-utils.com.br/utilities.md#generatepis): Generate a valid random PIS. +- [generateProcessoJuridico](https://brazilian-utils.com.br/utilities.md#generateprocessojuridico): Generate a valid random processo jurídico number according to CNJ's definition. +- [generatePassport](https://brazilian-utils.com.br/utilities.md#generatepassport): Generate a random valid Brazilian passport number. +- [generateCnh](https://brazilian-utils.com.br/utilities.md#generatecnh): Generate a valid random CNH. +- [generateLegalNature](https://brazilian-utils.com.br/utilities.md#generatelegalnature): Generate a random valid legal nature code. - [generateVoterId](https://brazilian-utils.com.br/utilities.md#generatevoterid): Generate a valid random voter ID number. ## Getters (get*) +- [getAddressInfoByCep](https://brazilian-utils.com.br/utilities.md#getaddressinfobycep): Fetch address information for a given CEP using multiple providers. +- [getCepInfoByAddress](https://brazilian-utils.com.br/utilities.md#getcepinfobyaddress): Fetch CEPs from an address using ViaCEP. - [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. +- [getFormatLicensePlate](https://brazilian-utils.com.br/utilities.md#getformatlicenseplate): Detect the normalized format of a license plate. - [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. -- [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. -- [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. - [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. +- [getCities](https://brazilian-utils.com.br/utilities.md#getcities): Get Brazilian cities. Deprecated: use `getMunicipalities` instead. +- [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`. +- [getHolidays](https://brazilian-utils.com.br/utilities.md#getholidays): Get Brazilian holidays for a given year. +- [getLegalNature](https://brazilian-utils.com.br/utilities.md#getlegalnature): Look a legal nature code up in the official IBGE/CONCLA table. +- [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. +- [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"`. - [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`). +- [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"`. +- [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"`. - [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. -- [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"`. +- [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?)`. +- [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. - [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. ## Optional diff --git a/docs/migration-v1-to-v2.md b/docs/migration-v1-to-v2.md index 45f40c923..6c80b4896 100644 --- a/docs/migration-v1-to-v2.md +++ b/docs/migration-v1-to-v2.md @@ -29,6 +29,17 @@ The library now uses modern ES module exports with proper `exports` field in `pa import { isValidCpf, formatCpf } from '@brazilian-utils/brazilian-utils'; ``` +Since 2.4.0 every util is also its own subpath entry, so a bundler that does not tree-shake (or a +plain `require`) still loads a single module, and the few heavy ones (`getCities`, +`getMunicipalities`, `isValidNcm`, `isValidCbo`, `isValidCnae`, `getBanks`) can be lazy-loaded: + +```javascript +import { isValidCpf } from '@brazilian-utils/brazilian-utils/is-valid-cpf'; // ~1.4 KB, 0.8 KB gzipped +const { getCities } = await import('@brazilian-utils/brazilian-utils/get-cities'); // only when needed +``` + +See [Bundle size](getting-started.md#bundle-size) for the sizes of every entry. + ### 📁 Simpler Structure The codebase has been reorganized for better maintainability: @@ -42,7 +53,7 @@ The codebase has been reorganized for better maintainability: Updated to modern, faster tooling: - **Build**: Migrated from `tsdx` to a **Vite+** toolchain for faster builds and scripts - **Testing**: Migrated from `jest` to **Vitest** (faster, Jest-compatible, ESM-native) -- **Linting/Formatting**: Migrated from `prettier` + `eslint` to **Biome** (faster, all-in-one) +- **Linting/Formatting**: Migrated from `prettier` + `eslint` to the Vite+ toolchain (`vp fmt` and `vp check`, backed by Oxc) - **TypeScript**: Modern configuration optimized for bundlers ### 🌐 Browser Testing @@ -63,8 +74,8 @@ npm run test:edge-browser ### 📦 Fewer Dependencies Reduced development dependencies while maintaining zero runtime dependencies: -- **v1**: Multiple tools (tsdx, jest, prettier, eslint, husky, lint-staged, commitlint, etc.) -- **v2**: Minimal dependencies (Vite+, Vitest browser support, webdriverio) +- **v1**: Multiple tools (tsdx, jest, prettier, eslint, husky, lint-staged, etc.) +- **v2**: One toolchain (Vite+ for build, lint, format and tests, with Vitest browser support through webdriverio) plus the quality gates listed in CONTRIBUTING.md (Stryker, knip, jscpd, API Extractor, commitlint) - Simpler maintenance and faster CI/CD pipelines - Zero runtime dependencies (maintained) @@ -80,6 +91,14 @@ Added new useful utilities: - `isValidRenavam` - Validate RENAVAM (vehicle registration number) - `isValidBankAccount` - Validate Brazilian bank accounts with specific algorithms for major banks +2.4.0 added many more families on top of these, all listed in the [utilities documentation](utilities.md): +Pix (`isValidPixKey`, `generatePixPayload`, `getPixPayloadInfo`), NF-e/DF-e keys, CNS, certidão, +CEI/CNO/CAEPF, IBAN, card numbers, VIN, professional registrations, bank lookups (`getBanks`, +`getBankByCode`, `getBankByIspb`), CBO/CNAE/NCM/CFOP/CST/CSOSN codes, business days +(`isBusinessDay`, `addBusinessDays`, `differenceInBusinessDays`), legal nature categories, offline +municipalities (`getMunicipalities`, `getMunicipalityByCode`), DDD and time zone lookups, numbers in +words, and a `capitalize` that knows the Brazilian company designations. + #### Alphanumeric CNPJ Support (Version 2) v2.0.0 adds support for the new alphanumeric CNPJ format introduced by the Brazilian Federal Revenue. Both `isValidCnpj` and `generateCnpj` now support version 2 (alphanumeric) CNPJs: @@ -131,7 +150,7 @@ To make the migration easier, **v2.x still exports the old PascalCase names as d | `isValidCNPJ` | `isValidCnpj` | | `isValidCEP` | `isValidCep` | | `isValidPIS` | `isValidPis` | -| `isValidIE` | `isValidIe` | +| `isValidIE` | `isValidIe` (since 2.4.0 prefer the object form, `isValidIe({ value, stateCode })`; the positional form is deprecated) | | `isValidProcessoJuridico` | `isValidProcessoJuridico` (unchanged) | | `isValidBoleto` | `isValidBoleto` (unchanged) | | `isValidEmail` | `isValidEmail` (unchanged) | @@ -148,7 +167,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 +200,8 @@ 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`) | +| `getMunicipality` | `getMunicipality` (deprecated in 2.4.0 in favour of `getMunicipalityByCode`, which is synchronous and offline) | | `getAddressInfoByCep` | `getAddressInfoByCep` (API changed, see below) | ### Migration Example @@ -238,16 +257,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 +319,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 +346,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 @@ -417,6 +432,10 @@ getCities(); // Returns sorted alphabetically getCities('SP'); // Returns sorted alphabetically ``` +**Since 2.4.0:** `getCities` is deprecated. `getMunicipalities('SP')` returns the same municipalities +with their IBGE codes (`{ code, name, stateCode }`), and `getMunicipalityByCode('3550308')` looks one +up without a network call. + ## Migration Checklist ### Required (before upgrading to v2.x) @@ -425,6 +444,10 @@ getCities('SP'); // Returns sorted alphabetically ### Optional (recommended before v3.0.0) - [ ] Update all imports to use camelCase function names - [ ] Replace all function calls with camelCase names +- [ ] Replace `getCities` with `getMunicipalities` and `getMunicipality` with `getMunicipalityByCode` (deprecated in 2.4.0) +- [ ] Call `isValidIe({ value, stateCode })` instead of `isValidIe(stateCode, ie)` (deprecated in 2.4.0) +- [ ] Import the `*Params` type names instead of the `*Options` aliases kept for the single-object-argument functions (deprecated in 2.4.0) +- [ ] Drop `'widenet'` from the `providers` of `getAddressInfoByCep` (the service is gone; deprecated in 2.4.0) ### Review if applicable - [ ] Update error handling for `getAddressInfoByCep` if needed @@ -438,4 +461,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 8f28bc9ad..feb4ce63e 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. @@ -59,23 +59,23 @@ import { isValidCpf } from '@brazilian-utils/brazilian-utils'; isValidCpf('1232454233345'); // false ``` -Você pode conferir a lista de utilitários [clicando aqui](utilities.md). +Você pode conferir a lista de utilitários [clicando aqui](pt-br/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,2 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` · `getBankByIspb` | 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 f45cf6478..2c3a5abb3 100644 --- a/docs/pt-br/migration-v1-to-v2.md +++ b/docs/pt-br/migration-v1-to-v2.md @@ -29,6 +29,18 @@ A biblioteca agora usa exports de módulos ES modernos com o campo `exports` ade import { isValidCpf, formatCpf } from '@brazilian-utils/brazilian-utils'; ``` +Desde a 2.4.0 cada utilitário também é um subpath próprio, então um bundler que não faz tree +shaking (ou um `require` simples) ainda carrega um único módulo, e os poucos pesados (`getCities`, +`getMunicipalities`, `isValidNcm`, `isValidCbo`, `isValidCnae`, `getBanks`) podem ser carregados sob +demanda: + +```javascript +import { isValidCpf } from '@brazilian-utils/brazilian-utils/is-valid-cpf'; // ~1,4 KB, 0,8 KB com gzip +const { getCities } = await import('@brazilian-utils/brazilian-utils/get-cities'); // só quando precisar +``` + +Veja [Tamanho do bundle](pt-br/getting-started.md#tamanho-do-bundle) para o tamanho de cada entrada. + ### Estrutura Mais Simples O código foi reorganizado para melhor manutenibilidade: @@ -42,7 +54,7 @@ O código foi reorganizado para melhor manutenibilidade: Atualizado para ferramentas modernas e mais rápidas: - **Build**: Migrado de `tsdx` para uma stack com **Vite+** para builds e scripts mais rápidos - **Testes**: Migrado de `jest` para **Vitest** (mais rápido, compatível com Jest, nativo ESM) -- **Linting/Formatação**: Migrado de `prettier` + `eslint` para **Biome** (mais rápido, tudo-em-um) +- **Linting/Formatação**: Migrado de `prettier` + `eslint` para a toolchain do Vite+ (`vp fmt` e `vp check`, sobre o Oxc) - **TypeScript**: Configuração moderna otimizada para bundlers ### Testes em Browsers @@ -63,8 +75,8 @@ npm run test:edge-browser ### Menos Dependências Redução de dependências de desenvolvimento mantendo zero dependências de runtime: -- **v1**: Múltiplas ferramentas (tsdx, jest, prettier, eslint, husky, lint-staged, commitlint, etc.) -- **v2**: Dependências mínimas (Vite+, suporte de browser do Vitest, webdriverio) +- **v1**: Múltiplas ferramentas (tsdx, jest, prettier, eslint, husky, lint-staged, etc.) +- **v2**: Uma toolchain (Vite+ para build, lint, formatação e testes, com suporte de browser do Vitest via webdriverio) mais os gates de qualidade listados no CONTRIBUTING.md (Stryker, knip, jscpd, API Extractor, commitlint) - Manutenção mais simples e pipelines CI/CD mais rápidos - Zero dependências de runtime (mantido) @@ -80,6 +92,14 @@ Adicionadas novas utilitários úteis: - `isValidRenavam` - Valida RENAVAM (número de registro de veículos) - `isValidBankAccount` - Valida contas bancárias brasileiras com algoritmos específicos para principais bancos +A 2.4.0 acrescentou muitas outras famílias a essas, todas listadas na [documentação de utilitários](pt-br/utilities.md): +Pix (`isValidPixKey`, `generatePixPayload`, `getPixPayloadInfo`), chave de NF-e/DF-e, CNS, certidão, +CEI/CNO/CAEPF, IBAN, número de cartão, VIN, registro profissional, consulta de bancos (`getBanks`, +`getBankByCode`, `getBankByIspb`), códigos CBO/CNAE/NCM/CFOP/CST/CSOSN, dias úteis (`isBusinessDay`, +`addBusinessDays`, `differenceInBusinessDays`), categorias de natureza jurídica, municípios offline +(`getMunicipalities`, `getMunicipalityByCode`), DDD e fuso horário, número por extenso e um +`capitalize` que conhece as designações societárias brasileiras. + #### Suporte a CNPJ Alfanumérico (Versão 2) A v2.0.0 adiciona suporte ao novo formato alfanumérico de CNPJ introduzido pela Receita Federal. Tanto `isValidCnpj` quanto `generateCnpj` agora suportam CNPJs versão 2 (alfanuméricos): @@ -131,7 +151,7 @@ Para facilitar a migração, **a v2.x ainda exporta os nomes antigos em PascalCa | `isValidCNPJ` | `isValidCnpj` | | `isValidCEP` | `isValidCep` | | `isValidPIS` | `isValidPis` | -| `isValidIE` | `isValidIe` | +| `isValidIE` | `isValidIe` (desde a 2.4.0 prefira a forma objeto, `isValidIe({ value, stateCode })`; a forma posicional está descontinuada) | | `isValidProcessoJuridico` | `isValidProcessoJuridico` (inalterado) | | `isValidBoleto` | `isValidBoleto` (inalterado) | | `isValidEmail` | `isValidEmail` (inalterado) | @@ -148,7 +168,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 +201,8 @@ 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`) | +| `getMunicipality` | `getMunicipality` (descontinuado na 2.4.0 em favor de `getMunicipalityByCode`, que é síncrono e offline) | | `getAddressInfoByCep` | `getAddressInfoByCep` (API alterada, veja abaixo) | ### Exemplo de Migração @@ -238,16 +258,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 +320,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 +347,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 @@ -417,6 +433,10 @@ getCities(); // Retorna ordenado alfabeticamente getCities('SP'); // Retorna ordenado alfabeticamente ``` +**Desde a 2.4.0:** `getCities` está descontinuado. `getMunicipalities('SP')` retorna os mesmos municípios +com o código do IBGE (`{ code, name, stateCode }`), e `getMunicipalityByCode('3550308')` busca um deles +sem chamada de rede. + ## Checklist de Migração ### Obrigatório (antes de atualizar para v2.x) @@ -425,6 +445,10 @@ getCities('SP'); // Retorna ordenado alfabeticamente ### Opcional (recomendado antes da v3.0.0) - [ ] Atualizar todas as importações para usar nomes de funções em camelCase - [ ] Substituir todas as chamadas de funções com nomes em camelCase +- [ ] Trocar `getCities` por `getMunicipalities` e `getMunicipality` por `getMunicipalityByCode` (descontinuados na 2.4.0) +- [ ] Chamar `isValidIe({ value, stateCode })` em vez de `isValidIe(stateCode, ie)` (descontinuado na 2.4.0) +- [ ] Importar os tipos `*Params` em vez dos aliases `*Options` mantidos para as funções de um único argumento objeto (descontinuados na 2.4.0) +- [ ] Tirar `'widenet'` dos `providers` do `getAddressInfoByCep` (o serviço acabou; descontinuado na 2.4.0) ### Revisar se aplicável - [ ] Atualizar tratamento de erros para `getAddressInfoByCep` se necessário @@ -438,4 +462,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 28c5b00ae..ee66e5c26 100644 --- a/docs/pt-br/utilities.md +++ b/docs/pt-br/utilities.md @@ -2,9 +2,9 @@ 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. +## CPF -## isValidCpf +### isValidCpf Valida se o CPF é válido. Aceita os caracteres de máscara usuais e espaços em branco entre/ao redor dos grupos. @@ -15,9 +15,9 @@ isValidCpf('155151475'); // false isValidCpf('111 444 777 35'); // true (máscara com espaços) ``` -## formatCpf +### 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'; @@ -27,7 +27,7 @@ formatCpf('746506880', { pad: true }); // 007.465.068-80 formatCpf('12345678909', { obfuscate: true }); // ***.456.789-** ``` -## parseCpf +### parseCpf Remove a formatação do CPF, mantém apenas os dígitos e limita o resultado a 11 dígitos. @@ -37,9 +37,9 @@ import { parseCpf } from '@brazilian-utils/brazilian-utils'; parseCpf('746.506.880-00'); // 74650688000 ``` -## generateCpf +### 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' @@ -48,9 +48,11 @@ generateCpf(); generateCpf('SP'); // o 9º dígito é 8, o código da região fiscal de SP ``` -## isValidCnpj +## CNPJ -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`. +### isValidCnpj + +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'; @@ -59,9 +61,9 @@ isValidCnpj('15515147234255'); // false isValidCnpj('q0slfmbd7vx439', { version: 2 }); // true (alfanumérico minúsculo) ``` -## formatCnpj +### 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'; @@ -72,9 +74,9 @@ formatCnpj('12OUT345000199', { version: 2 }); // 12.OUT.345/0001-99 formatCnpj('12345678000195', { obfuscate: true }); // **.345.678/0001-** ``` -## parseCnpj +### 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'; @@ -83,9 +85,24 @@ parseCnpj('24.522.200/0001-74'); // 24522200000174 parseCnpj('12.OUT.345/0001-99', { version: 2 }); // 12OUT345000199 ``` -## isValidCep +### generateCnpj + +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 `GenerateCnpjParams` 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' +``` + +## CEP e endereço -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. +### isValidCep + +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,26 +110,100 @@ 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) ``` -## generateCnpj +### formatCep -Gera um CNPJ válido aleatório. +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 { generateCnpj } from '@brazilian-utils/brazilian-utils' +import { formatCep } from '@brazilian-utils/brazilian-utils'; -generateCnpj(); -generateCnpj(2); // CNPJ alfanumérico, ex. 'Q0SLFMBD7VX439' +formatCep('92500000'); // 92500-000 +formatCep('9250000', { pad: true }); // 09250-000 +``` + +### parseCep + +Remove a formatação do CEP, mantém apenas os dígitos e limita o resultado a 8 dígitos. + +```javascript +import { parseCep } from '@brazilian-utils/brazilian-utils'; + +parseCep('92500-000'); // 92500000 +``` + +### generateCep + +Gera um CEP aleatório. Usa `Math.random()` internamente, então não é criptograficamente seguro. + +```javascript +import { generateCep } from '@brazilian-utils/brazilian-utils'; + +generateCep(); // '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`. 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'; + +// Usando os provedores padrão (['viacep', 'brasilapi']) +const address = await getAddressInfoByCep('01310100'); +// { cep: '01310100', state: 'SP', city: 'São Paulo', neighborhood: 'Bela Vista', street: 'Avenida Paulista' } + +// Usando provedores específicos +const addressFromProviders = await getAddressInfoByCep('01310-100', { + providers: ['viacep', 'brasilapi'] +}); + +// Usando número como entrada (será preenchido automaticamente com zeros à esquerda) +const addressFromNumber = await getAddressInfoByCep(1310100); +``` + +### 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 — 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: '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' +// } +// ] ``` -## isValidBoleto +## Boleto + +### 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'; @@ -121,9 +212,9 @@ isValidBoleto('00190000090114971860168524522114675860000102656'); // true isValidBoleto('846100000005246100291102005460339004695895061080'); // true (boleto de arrecadação) ``` -## formatBoleto +### 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'; @@ -134,7 +225,7 @@ formatBoleto('846100000005246100291102005460339004695895061080'); // 84610000000 formatBoleto('84610000000246100291100054603390069589506108'); // 84610.00000 02461.002911 00054.603390 0 69589506108 (código de barras de arrecadação de 44 dígitos mantém a máscara bancária) ``` -## parseBoleto +### parseBoleto Remove a formatação do boleto, mantém apenas os dígitos e limita o resultado a 47 dígitos (48 para boleto de arrecadação). @@ -144,9 +235,9 @@ import { parseBoleto } from '@brazilian-utils/brazilian-utils'; parseBoleto('00190.00009 01149.718601 68524.522114 6 75860000102656'); // 00190000090114971860168524522114675860000102656 ``` -## generateBoleto +### 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 `GenerateBoletoParams`) 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'; @@ -155,9 +246,9 @@ generateBoleto(); // "00190000090114971860168524522114675860000102656" generateBoleto({ type: 'arrecadacao' }); // "846100000005246100291102005460339004695895061080" ``` -## getBoletoInfo +### 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,9 +263,13 @@ getBoletoInfo('00190000090114971860168524522114675860000102656', { getBoletoInfo('846100000005246100291102005460339004695895061080'); // { amount: 2461, expirationDate: null, bankCode: '', type: 'arrecadacao', segment: 4, value: 24.61, hasEffectiveValue: true } + +getBoletoInfo('invalid'); // null ``` -## isValidPixKey +## Pix + +### isValidPixKey Valida se uma chave Pix é válida: um CPF, um CNPJ, um e-mail, um telefone celular brasileiro ou uma chave aleatória (EVP), conforme os formatos de chave do DICT. O manual registra um "número de telefone celular", então um telefone fixo não é uma chave Pix válida. `options.accept` (tipado como `IsValidPixKeyOptions`) restringe quais tipos de chave são aceitos; o padrão é aceitar todos, e `[]` rejeita todos. Exporta o tipo `PixKeyType`. @@ -190,26 +285,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 +### 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 +317,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 +### 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 `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. 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'; @@ -267,72 +363,97 @@ generatePixPayload({ generatePixPayload({ merchantName: 'Fulano', merchantCity: 'Brasília' }); // null (nem key nem url) ``` -## isValidNfeKey +## Chave de NF-e + +### isValidNfeKey + +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. -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. +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 +### 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 +### 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', -// 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' ``` -## isValidEmail +### getNfeKeyInfo -Valida se email é válido. +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 { isValidEmail } from '@brazilian-utils/brazilian-utils'; +import { getNfeKeyInfo } from '@brazilian-utils/brazilian-utils'; -isValidEmail('john.doe@hotmail.com'); // true +getNfeKeyInfo('35170458716523000119550010000000121000123458'); +// { stateCode: 'SP', year: 2017, month: 4, taxId: '58716523000119', model: '55', +// series: 1, number: 12, emissionType: 1, code: '00012345', checkDigit: 8 } + +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 ``` -## isValidPhone +## Telefone + +### 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 isValidPhone('11900000000', { accept: [] }); // false ``` -## formatPhone +### 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 +461,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 @@ -348,7 +471,7 @@ formatPhone('+5511987654321', { mask: 'auto' }); // +55 11 98765-4321 ("auto" de formatPhone('11900000000'); // 11900-0000 (CUIDADO: a máscara padrão "sn" trunca um número com DDD) ``` -## parsePhone +### parsePhone Remove a formatação do telefone, mantém apenas os dígitos e limita o resultado a 11 dígitos. Um código de país brasileiro é removido antes, mas somente quando os dígitos restantes tiverem exatamente 10 ou 11 dígitos, ou seja, um número nacional plausível. A regra é baseada no tamanho, não no sinal, então um número da área 55 não é confundido com o código de país. @@ -361,21 +484,36 @@ parsePhone('5511987654321'); // 11987654321 parsePhone('55987654321'); // 55987654321 (DDD 55, não confundido com o código de país +55) ``` -## isValidMobilePhone +### 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. 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'; + +generatePhone(); // '11912345678' ou '1131234567' +generatePhone('mobile'); // '11912345678' +generatePhone('landline'); // '1131234567' +generatePhone('service'); // '08001234567' ou '40041234' +``` + +### 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 +### 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'; @@ -383,9 +521,9 @@ import { isValidLandlinePhone } from '@brazilian-utils/brazilian-utils'; isValidLandlinePhone('1130000000'); // true ``` -## isValidServicePhone +### 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'; @@ -396,30 +534,30 @@ isValidServicePhone('190'); // true isValidServicePhone('11987654321'); // false (número geográfico) ``` -## getAreaCodeInfo +### getAreaCodeInfo 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 getAreaCodeInfo(1.1); // null ``` -## getAreaCodesByState +### getAreaCodesByState Retorna todos os DDDs (códigos de área) que atendem um determinado estado brasileiro, dentro do Plano Geral de Numeração da Anatel. A comparação não diferencia maiúsculas de minúsculas e o resultado vem ordenado de forma crescente. @@ -436,7 +574,9 @@ getAreaCodesByState('SC'); // [42, 47, 48, 49] getAreaCodesByState('XX'); // [] ``` -## isValidLicensePlate +## Placa de veículo + +### isValidLicensePlate Valida se a placa de carro ou moto é válida. Suporta o formato antigo brasileiro (ABC-1234) e o formato Mercosul (ABC1D23), a sequência única que a Resolução CONTRAN nº 969/2022 define para todo veículo, motos incluídas. @@ -451,110 +591,167 @@ isValidLicensePlate('ABC12D3'); // false (não é uma sequência Mercosul) isValidLicensePlate('ABC1234EXTRA'); // false (caracteres em excesso) ``` -## isValidRenavam +### formatLicensePlate -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). +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 { isValidRenavam } from '@brazilian-utils/brazilian-utils'; +import { formatLicensePlate } from '@brazilian-utils/brazilian-utils'; -isValidRenavam('639884962'); // true (9 dígitos, formato antigo) -isValidRenavam('00639884962'); // true (11 dígitos, formato novo) -isValidRenavam('12345678901'); // false (checksum inválido) +formatLicensePlate('abc1234'); // 'ABC-1234' +formatLicensePlate('abc1d23'); // 'ABC1D23' ``` -## isValidPis +### parseLicensePlate -Valida se o PIS é válido. Aceita os caracteres de máscara usuais (`.`, `-`, `/`, `(`, `)`, `,`, `*`) e espaços em branco. +Remove separadores de uma placa, normaliza para letras maiúsculas e limita o resultado a 7 caracteres. ```javascript -import { isValidPis } from '@brazilian-utils/brazilian-utils'; +import { parseLicensePlate } from '@brazilian-utils/brazilian-utils'; -isValidPis('12056412547'); // false +parseLicensePlate('abc-1234'); // 'ABC1234' ``` -## formatPis +### generateLicensePlate -Formata número de PIS. +Gera uma placa aleatória no formato escolhido. Usa `Math.random()` internamente, então não é criptograficamente seguro. ```javascript -import { formatPis } from '@brazilian-utils/brazilian-utils'; +import { generateLicensePlate } from '@brazilian-utils/brazilian-utils'; -formatPis('12345678901'); // 123.45678.90-1 -formatPis('123456789', { pad: true }); // 001.23456.78-9 +generateLicensePlate(); // 'ABC1D23' (Mercosul, o padrão) +generateLicensePlate('LLLNNNN'); // 'ABC1234' +generateLicensePlate('LLLNNLN'); // 'ABC1D23' (um formato fora dos dois em circulação cai no padrão) ``` -## parsePis +Um `format` fora dos dois literais suportados recai no padrão Mercosul, como todo gerador deste pacote faz com uma opção que não conhece, então o resultado é sempre uma placa que `isValidLicensePlate` aceita. Essa sequência padrão é `LLLNLNN`, da Resolução CONTRAN nº 969/2022, Anexo I item 1.2, a única sequência que a resolução define para todo veículo, motocicletas incluídas. (A versão 2.3.0 usava uma string desconhecida literalmente, então `generateLicensePlate('LLLNNLN')` produzia a sequência de motocicleta que foi retirada e `generateLicensePlate('bogus')`, cinco dígitos; nenhuma das duas é uma placa.) -Remove a formatação do PIS, mantém apenas os dígitos e limita o resultado a 11 dígitos. +### getFormatLicensePlate + +Detecta o formato normalizado de uma placa. ```javascript -import { parsePis } from '@brazilian-utils/brazilian-utils'; +import { getFormatLicensePlate } from '@brazilian-utils/brazilian-utils'; -parsePis('123.45678.90-1'); // 12345678901 +getFormatLicensePlate('ABC-1234'); // 'LLLNNNN' +getFormatLicensePlate('ABC1D23'); // 'LLLNLNN' +getFormatLicensePlate('ABC12D3'); // null (não é uma sequência Mercosul) +getFormatLicensePlate('INVALID'); // null +getFormatLicensePlate('ABC1234EXTRA'); // null (caracteres em excesso) ``` -## formatCep +`getFormatLicensePlate` exporta o tipo `LicensePlateFormat` (`"LLLNNNN" | "LLLNLNN"`); `generateLicensePlate` reexporta como `GenerateLicensePlateFormat`. + +### convertLicensePlateToMercosul -Formata o CEP. +Converte uma placa brasileira no formato antigo (`LLLNNNN`) para o formato Mercosul (`LLLNLNN`), seguindo a tabela oficial de conversão: o dígito na 5ª posição vira uma letra (`0` a `9` mapeados para `A` a `J`). Retorna `""` quando o valor não é uma placa válida no formato antigo. ```javascript -import { formatCep } from '@brazilian-utils/brazilian-utils'; +import { convertLicensePlateToMercosul } from '@brazilian-utils/brazilian-utils'; -formatCep('92500000'); // 92500-000 +convertLicensePlateToMercosul('ABC1234'); // 'ABC1C34' +convertLicensePlateToMercosul('abc-1234'); // 'ABC1C34' +convertLicensePlateToMercosul('ABC1D23'); // '' (já está no formato Mercosul) ``` -## parseCep +## RENAVAM -Remove a formatação do CEP, mantém apenas os dígitos e limita o resultado a 8 dígitos. +### 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). 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 { parseCep } from '@brazilian-utils/brazilian-utils'; +import { isValidRenavam } from '@brazilian-utils/brazilian-utils'; -parseCep('92500-000'); // 92500000 +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) ``` -## getAddressInfoByCep +### generateRenavam -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`. +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 { getAddressInfoByCep } from '@brazilian-utils/brazilian-utils'; +import { generateRenavam } from '@brazilian-utils/brazilian-utils'; -// Usando os provedores padrão (['viacep', 'brasilapi']) -const address = await getAddressInfoByCep('01310100'); -// { cep: '01310100', state: 'SP', city: 'São Paulo', neighborhood: 'Bela Vista', street: 'Avenida Paulista' } +generateRenavam(); // '12345678900' +``` -// Usando provedores específicos -const address = await getAddressInfoByCep('01310-100', { - providers: ['viacep', 'brasilapi'] -}); +## PIS -// Usando número como entrada (será preenchido automaticamente com zeros à esquerda) -const address = await getAddressInfoByCep(1310100); +### isValidPis + +Valida se o PIS é válido. Aceita os caracteres de máscara usuais (`.`, `-`, `/`, `(`, `)`, `,`, `*`) e espaços em branco. + +```javascript +import { isValidPis } from '@brazilian-utils/brazilian-utils'; + +isValidPis('12056412547'); // false +``` + +### formatPis + +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'; + +formatPis('12345678901'); // 123.45678.90-1 +formatPis('123456789', { pad: true }); // 001.23456.78-9 +``` + +### parsePis + +Remove a formatação do PIS, mantém apenas os dígitos e limita o resultado a 11 dígitos. + +```javascript +import { parsePis } from '@brazilian-utils/brazilian-utils'; + +parsePis('123.45678.90-1'); // 12345678901 +``` + +### generatePis + +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'; + +generatePis(); // '91077906857' ``` -## isValidProcessoJuridico +## Processo jurídico + +### 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 +### 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 +### parseProcessoJuridico Remove a formatação do processo jurídico, mantém apenas os dígitos e limita o resultado a 20 dígitos. Tanto a máscara atual do CNJ (`NNNNNNN-DD.AAAA.J.TR.OOOO`) quanto a máscara antiga são aceitas, já que apenas os dígitos são mantidos. @@ -564,30 +761,34 @@ import { parseProcessoJuridico } from '@brazilian-utils/brazilian-utils'; parseProcessoJuridico('0002080-25.2012.5.15.0049'); // 00020802520125150049 ``` -## isValidIe +### generateProcessoJuridico -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`). +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 { isValidIe } from '@brazilian-utils/brazilian-utils'; +import { generateProcessoJuridico } from '@brazilian-utils/brazilian-utils'; -isValidIe('AC', '0187634580933'); // false -isValidIe('go', '109161793'); // true (case-insensitive) +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) ``` -## isValidBankAccount +## Contas bancárias e bancos -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. +### 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 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 +798,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. @@ -680,7 +879,7 @@ isValidBankAccount({ }); // true (Banco ABC Brasil, fallback genérico mod10) ``` -## getBanks +### getBanks Obtém todos os bancos brasileiros com código de compensação (COMPE), publicados pelo Banco Central do Brasil na [lista de participantes do STR](https://www.bcb.gov.br/content/estabilidadefinanceira/str1/ParticipantesSTR.csv). Cada banco (tipado como `Bank`) tem um `code` (COMPE, 3 dígitos), um `ispb` (Identificador do Sistema de Pagamentos Brasileiro, 8 dígitos) e um `name`. Cada chamada retorna um novo array com novos objetos, então alterar o resultado nunca afeta chamadas seguintes. @@ -696,7 +895,7 @@ getBanks(); // ] ``` -## getBankByCode +### getBankByCode Busca um banco brasileiro pelo seu código de compensação (COMPE), publicado pelo Banco Central do Brasil na [lista de participantes do STR](https://www.bcb.gov.br/content/estabilidadefinanceira/str1/ParticipantesSTR.csv). 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 código. @@ -708,9 +907,9 @@ getBankByCode(1); // { code: '001', ispb: '00000000', name: 'Banco do Brasil S.A getBankByCode('999'); // null ``` -## getBankByIspb +### 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'; @@ -720,23 +919,26 @@ getBankByIspb('60701190'); // { code: '341', ispb: '60701190', name: 'ITAÚ UNIB getBankByIspb('99999999'); // null ``` -## isValidIban +## IBAN + +### 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 +### 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 +946,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 +### 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,45 +978,15 @@ 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. - -```javascript -import { isValidCreditCard } from '@brazilian-utils/brazilian-utils'; - -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('4111111111111112'); // false (dígito verificador inválido) -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`. - -```javascript -import { capitalize } from '@brazilian-utils/brazilian-utils'; - -capitalize('josé e maria'); // José e Maria -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(' josé maria '); // José Maria (toda sequência de espaço em branco, tabs e quebras de linha inclusive, vira um único espaço) -``` +## Moeda, números e datas por extenso -## formatCurrency +### 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'; @@ -819,9 +1002,9 @@ formatCurrency('-10.5'); // -10,50 (o "-" inicial é preservado) formatCurrency(Number.NaN); // "" (números não finitos viram string vazia) ``` -## parseCurrency +### 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'; @@ -837,9 +1020,9 @@ parseCurrency('R$ 1,001', { precision: 3 }); // 1.001 parseCurrency(''); // 0 ``` -## convertNumberToWords +### 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,28 +1032,48 @@ 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 +### 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 +### 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. 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'; + +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('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" +convertDateToWords('10/05/1999'); // "dez de maio de mil novecentos e noventa e nove" +convertDateToWords('31/04/2024'); // "" (abril tem 30 dias) +convertDateToWords('invalid'); // "" +convertDateToWords('29/02/1900'); // "" (1900 não é bissexto) +``` + +## Estados e municípios + +### getStates Retorna todos os estados brasileiros, cada um com sigla, nome, código da região, nome da região e código IBGE de 2 dígitos da Unidade da Federação (`cUF`). A lista é ordenada por nome com `localeCompare` no locale "pt-BR", então nomes acentuados caem onde um leitor brasileiro espera: Pará, Paraíba, Paraná e Rio de Janeiro, Rio Grande do Norte, Rio Grande do Sul. Cada chamada retorna um array novo com objetos novos, então alterar o resultado nunca afeta chamadas seguintes. Exporta os tipos `State`, `StateCode` e `StateName`. `State` é uma união discriminada com um membro por estado, então os campos de um estado ficam amarrados entre si: estreitar um `State` pelo `code` também estreita `name`, `regionCode`, `regionName` e `ibgeCode` (`Extract['name']` é `'São Paulo'`), e uma combinação impossível como `{ code: 'SP', name: 'Acre' }` não é um `State`. @@ -909,9 +1112,9 @@ getStates(); // ] ``` -## getStateByIbgeCode +### 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'; @@ -927,9 +1130,9 @@ getStateByIbgeCode(-35); // null getStateByIbgeCode(3.5); // null ``` -## getStateCodeByName +### 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'; @@ -940,7 +1143,7 @@ getStateCodeByName(' Rio de Janeiro '); // 'RJ' getStateCodeByName('Neverland'); // null ``` -## getStateNameByCode +### getStateNameByCode Retorna o nome completo de um estado brasileiro a partir da sigla. A comparação ignora maiúsculas/minúsculas e espaços nas pontas, então `'sp'`, `'SP'` e `' Sp '` resolvem para `'São Paulo'`. Exporta o tipo `StateName`. @@ -953,7 +1156,7 @@ getStateNameByCode(' Rj '); // 'Rio de Janeiro' getStateNameByCode('ZZ'); // null ``` -## getTimezoneByState +### getTimezoneByState Retorna o nome do fuso horário do banco de dados IANA (tzdata) para um estado brasileiro, escolhido como o fuso da capital do estado. A comparação ignora maiúsculas/minúsculas e espaços nas pontas. Alguns fusos do tzdata cobrem mais de um estado: `America/Sao_Paulo` também cobre DF, GO, MG, ES, RJ, PR, SC e RS além de SP, e `America/Fortaleza` também cobre MA, PI, RN e PB além do CE. Pernambuco resolve para `America/Recife`, não `America/Noronha`: Fernando de Noronha é um distrito arquipélago de PE, não um estado próprio. @@ -967,519 +1170,478 @@ getTimezoneByState('PE'); // 'America/Recife' getTimezoneByState('ZZ'); // null ``` -## getCities +### getMunicipalities -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 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 { getCities } from '@brazilian-utils/brazilian-utils'; +import { getMunicipalities } from '@brazilian-utils/brazilian-utils'; -// Retorna todas as cidades brasileiras (ordenadas alfabeticamente). -getCities(); +// Retorna todos os municípios brasileiros (ordenados por nome). +getMunicipalities(); // [ -// 'Abadia de Goiás', -// 'Abadia dos Dourados', -// 'Abadiânia', -// 'Abaeté', -// 'Abaetetuba', -// 'Abaiara', -// 'Abaíra', -// 'Abaré', -// 'Abatiá', -// 'Abdon Batista', -// ... 5561 more items +// { code: '5200050', name: 'Abadia de Goiás', stateCode: 'GO' }, +// { code: '3100104', name: 'Abadia dos Dourados', stateCode: 'MG' }, +// { code: '5200100', name: 'Abadiânia', stateCode: 'GO' }, +// { code: '3100203', name: 'Abaeté', stateCode: 'MG' }, +// { code: '1500107', name: 'Abaetetuba', stateCode: 'PA' }, +// ... mais 5566 itens // ] -// Retorna todas as cidades brasileiras do estado de São Paulo (ordenadas alfabeticamente). -getCities('SP'); +// Retorna todos os municípios do estado de São Paulo. +getMunicipalities('SP'); // [ -// "Adamantina", -// "Adolfo", -// "Aguaí", -// "Águas da Prata", -// "Águas de Lindóia", -// "Águas de Santa Bárbara", -// "Águas de São Pedro", -// "Agudos", -// "Alambari", -// "Alfredo Marcondes", -// ... 635 more items +// { code: '3500105', name: 'Adamantina', stateCode: 'SP' }, +// { code: '3500204', name: 'Adolfo', stateCode: 'SP' }, +// { code: '3500303', name: 'Aguaí', stateCode: 'SP' }, +// { code: '3500402', name: 'Águas da Prata', stateCode: 'SP' }, +// { code: '3500501', name: 'Águas de Lindóia', stateCode: 'SP' }, +// ... mais 640 itens // ] + +getMunicipalities('ZZ'); // [] ``` -`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. +`getMunicipalities` embute todos os 5571 municípios do IBGE e seus códigos, então carrega o mesmo custo de tamanho de pacote que `getCities`. Veja [Tamanho do bundle](pt-br/getting-started.md#tamanho-do-bundle) para saber como carregá-lo sob demanda via `@brazilian-utils/brazilian-utils/get-municipalities` em vez do import da raiz. -## getHolidays +### getMunicipalityByCode -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. +Busca um município brasileiro pelo código IBGE de 7 dígitos. Aceita o código como string ou número, removendo qualquer caractere não numérico antes de comparar; um código informado como número precisa ser um inteiro não negativo, então `-3550308` e `355030.8` retornam `null` em vez de serem lidos como `3550308`. Retorna `{ code, name, stateCode }`, um objeto novo, ou `null` quando o código não tem 7 dígitos ou não corresponde a nenhum município conhecido. ```javascript -import { getHolidays } from '@brazilian-utils/brazilian-utils'; - -// Obtém todos os feriados nacionais de 2024 -getHolidays(2024); -// [ -// { name: 'Ano novo', date: Date('2024-01-01'), type: 'national' }, -// { name: 'Carnaval (terça-feira)', date: Date('2024-02-13'), type: 'optional' }, -// { name: 'Sexta-feira Santa', date: Date('2024-03-29'), type: 'national' }, -// { name: 'Páscoa', date: Date('2024-03-31'), type: 'religious' }, -// { name: 'Dia da Consciência Negra', date: Date('2024-11-20'), type: 'national' }, -// // ... mais feriados -// ] - -// Obtém feriados para um estado específico -getHolidays({ year: 2024, stateCode: 'SP' }); -// Inclui feriados nacionais mais feriados estaduais (ex: "Revolução Constitucionalista") -``` - -## isValidPassport +import { getMunicipalityByCode } from '@brazilian-utils/brazilian-utils'; -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. +getMunicipalityByCode('3550308'); +// { code: '3550308', name: 'São Paulo', stateCode: 'SP' } -```javascript -import { isValidPassport } from '@brazilian-utils/brazilian-utils'; +getMunicipalityByCode(3550308); +// { code: '3550308', name: 'São Paulo', stateCode: 'SP' } -isValidPassport('AB123456'); // true -isValidPassport('ab123456'); // true (case-insensitive) -isValidPassport('AB-123.456'); // true (símbolos são ignorados) -isValidPassport('12345678'); // false +getMunicipalityByCode('0000000'); // null (código desconhecido) +getMunicipalityByCode('123'); // null (não tem 7 dígitos) ``` -## formatPassport +### getCities -Formata um número de passaporte brasileiro (maiúsculas, sem símbolos, limitado a 8 caracteres). Uma entrada que não seja `string` retorna uma string vazia. +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 { formatPassport } from '@brazilian-utils/brazilian-utils'; - -formatPassport('ab123456'); // 'AB123456' -formatPassport('AB-123.456'); // 'AB123456' -``` - -## generatePassport - -Gera um número de passaporte brasileiro válido aleatoriamente. +import { getCities } from '@brazilian-utils/brazilian-utils'; -```javascript -import { generatePassport } from '@brazilian-utils/brazilian-utils'; +// Retorna todas as cidades brasileiras (ordenadas alfabeticamente). +getCities(); +// [ +// 'Abadia de Goiás', +// 'Abadia dos Dourados', +// 'Abadiânia', +// 'Abaeté', +// 'Abaetetuba', +// 'Abaiara', +// 'Abaíra', +// 'Abaré', +// 'Abatiá', +// 'Abdon Batista', +// ... mais 5561 itens +// ] -generatePassport(); // 'RY393097' +// Retorna todas as cidades brasileiras do estado de São Paulo (ordenadas alfabeticamente). +getCities('SP'); +// [ +// "Adamantina", +// "Adolfo", +// "Aguaí", +// "Águas da Prata", +// "Águas de Lindóia", +// "Águas de Santa Bárbara", +// "Águas de São Pedro", +// "Agudos", +// "Alambari", +// "Alfredo Marcondes", +// ... mais 635 itens +// ] ``` -## parsePassport +`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](pt-br/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. -Remove todos os caracteres não alfanuméricos de um número de passaporte, converte para maiúsculas e limita o resultado a 8 caracteres. Uma entrada que não seja `string` retorna uma string vazia. +### getMunicipality -```javascript -import { parsePassport } from '@brazilian-utils/brazilian-utils'; - -parsePassport('AB-123.456'); // 'AB123456' -parsePassport(' AB 123 456 '); // 'AB123456' -``` - -## generateCep - -Gera um CEP aleatório. +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 { generateCep } from '@brazilian-utils/brazilian-utils'; +import { getMunicipality } from '@brazilian-utils/brazilian-utils'; -generateCep(); // '92500000' -``` +await getMunicipality({ code: '3550308' }); +// ['São Paulo', 'SP'] -## formatCnh +await getMunicipality({ code: 3550308 }); +// ['São Paulo', 'SP'] -Formata a CNH. +await getMunicipality({ municipalityName: 'sao paulo', uf: 'sp' }); +// '3550308' -```javascript -import { formatCnh } from '@brazilian-utils/brazilian-utils'; +await getMunicipality({ code: '0000000' }); +// null (código desconhecido) -formatCnh('02650306461'); // 026503064-61 -formatCnh('2650306461', { pad: true }); // 026503064-61 +await getMunicipality({ code: '123' }); +// null (não tem 7 dígitos) ``` -## isValidCnh +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 `GetMunicipalityParams`) resolve para a união das duas. Os nomes da 2.3.0 `GetMunicipalityOptions`, `GetMunicipalityByCodeOptions` e `GetMunicipalityByNameOptions` continuam exportados como aliases deprecados destes. -Valida se a CNH é válida. +```typescript +import { + getMunicipality, + type GetMunicipalityByCodeParams, + type GetMunicipalityByNameParams, + type GetMunicipalityParams, +} from '@brazilian-utils/brazilian-utils'; -```javascript -import { isValidCnh } from '@brazilian-utils/brazilian-utils'; +const byCode: GetMunicipalityByCodeParams = { code: '3550308' }; +const byName: GetMunicipalityByNameParams = { municipalityName: 'sao paulo', uf: 'sp' }; -isValidCnh('00000000119'); // true -``` - -## generateCnh - -Gera uma CNH válida aleatória. +await getMunicipality(byCode); +// Promise<[string, string] | null> -```javascript -import { generateCnh } from '@brazilian-utils/brazilian-utils'; +await getMunicipality(byName); +// Promise -generateCnh(); // '02650306461' +const lookUp = (options: GetMunicipalityParams) => getMunicipality(options); +// (options: GetMunicipalityParams) => Promise<[string, string] | string | null> ``` -## parseCnh +## Feriados e dias úteis -Remove a formatação da CNH, mantém apenas os dígitos e limita o resultado a 11 dígitos. +### getHolidays -```javascript -import { parseCnh } from '@brazilian-utils/brazilian-utils'; +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, com o mesmo nome `"Dia da Consciência Negra"` em MT, RJ, AM e SP, e com `"Dia Estadual da Consciência Negra"` no AP, o nome que a lei daquele estado usa. Datas comemorativas que nenhuma lei transforma em feriado não entram na lista: o "Dia do Rio Grande do Norte" do RN (7 de agosto, Lei RN nº 7.831/2000) é uma delas, e o "Dia dos Evangélicos" de RO (18 de junho) também não entra, porque o STF derrubou a lei que o criou na ADI 3940. 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 `[]`. -parseCnh('026503064-61'); // '02650306461' -``` +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: -## getCepInfoByAddress +- **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. -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`. +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 { getCepInfoByAddress } from '@brazilian-utils/brazilian-utils'; - -const ceps = await getCepInfoByAddress({ - federalUnit: 'SP', - city: 'Sao Paulo', - street: 'Avenida Paulista' -}); +import { getHolidays } from '@brazilian-utils/brazilian-utils'; +// Obtém todos os feriados nacionais de 2024 +getHolidays(2024); // [ -// { -// cep: '01310100', -// logradouro: 'Avenida Paulista', -// complemento: 'lado par', -// bairro: 'Bela Vista', -// localidade: 'São Paulo', -// uf: 'SP' -// } +// { name: 'Ano novo', date: Date('2024-01-01'), type: 'national' }, +// { name: 'Carnaval (terça-feira)', date: Date('2024-02-13'), type: 'optional' }, +// { name: 'Sexta-feira Santa', date: Date('2024-03-29'), type: 'national' }, +// { name: 'Páscoa', date: Date('2024-03-31'), type: 'religious' }, +// { name: 'Dia da Consciência Negra', date: Date('2024-11-20'), type: 'national' }, +// // ... mais feriados // ] -``` - -## 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. - -```javascript -import { generateProcessoJuridico } from '@brazilian-utils/brazilian-utils'; - -generateProcessoJuridico(); // '89478643020269670326' -generateProcessoJuridico({ year: 2026, court: 5 }); // string | null -generateProcessoJuridico({ year: 10000 }); // null (ano fora do intervalo) -``` - -## formatLegalNature - -Formata um código de natureza jurídica. - -```javascript -import { formatLegalNature } from '@brazilian-utils/brazilian-utils'; - -formatLegalNature('2062'); // 206-2 -``` - -## isValidLegalNature - -Valida se um código de natureza jurídica existe na lista oficial. A tabela segue a "Natureza Jurídica 2021" do IBGE/CONCLA: 92 códigos oficiais mais 8 códigos legados mantidos por compatibilidade. Somente os caracteres de máscara usuais (hífens, pontos, espaços) são tolerados ao redor dos 4 dígitos, então `'2062a'` é rejeitado em vez de ser lido como `'2062'`. - -```javascript -import { isValidLegalNature } from '@brazilian-utils/brazilian-utils'; - -isValidLegalNature('2062'); // true -isValidLegalNature('9999'); // false +// Obtém feriados para um estado específico +getHolidays({ year: 2024, stateCode: 'SP' }); +// Inclui feriados nacionais mais feriados estaduais (ex: "Revolução Constitucionalista") ``` -## generateLegalNature +### isHoliday -Gera um código de natureza jurídica válido aleatório. +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 { generateLegalNature } from '@brazilian-utils/brazilian-utils'; +import { isHoliday } from '@brazilian-utils/brazilian-utils'; -generateLegalNature(); // '2062' +isHoliday({ targetDate: new Date(2024, 0, 1) }); // true +isHoliday({ targetDate: new Date(2024, 6, 9), stateCode: 'SP' }); // true +isHoliday(); // false ``` -## parseLegalNature +### isBusinessDay -Remove a formatação da natureza jurídica, mantém apenas os dígitos e limita o resultado a 4 dígitos. +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 { parseLegalNature } from '@brazilian-utils/brazilian-utils'; +import { isBusinessDay } from '@brazilian-utils/brazilian-utils'; -parseLegalNature('206-2'); // '2062' +isBusinessDay(new Date(2024, 0, 2)); // true (terça-feira, não é feriado) +isBusinessDay(new Date(2024, 0, 1)); // false (Ano novo) +isBusinessDay(new Date(2024, 0, 6)); // false (sábado) +isBusinessDay(new Date(2024, 1, 13)); // false (Carnaval, feriado opcional, conta por padrão) +isBusinessDay(new Date(2024, 1, 13), { includeOptional: false }); // true +isBusinessDay(new Date(2024, 6, 9), { stateCode: 'SP' }); // false (Revolução Constitucionalista) +isBusinessDay(new Date(2024, 6, 9)); // true (feriado estadual ignorado sem stateCode) +isBusinessDay(new Date('not a date')); // false ``` -## getLegalNatures +### addBusinessDays -Retorna o mapa de naturezas jurídicas indexado pelo código. +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 { getLegalNatures } from '@brazilian-utils/brazilian-utils'; - -const legalNatures = getLegalNatures(); +import { addBusinessDays } from '@brazilian-utils/brazilian-utils'; -legalNatures['2062']; // 'Sociedade Empresária Limitada' +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) ``` -## getLegalNature +### subBusinessDays -Busca um código de natureza jurídica na tabela oficial do IBGE/CONCLA. +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 { getLegalNature } from '@brazilian-utils/brazilian-utils'; +import { subBusinessDays } from '@brazilian-utils/brazilian-utils'; -getLegalNature('2062'); // { code: '2062', description: 'Sociedade Empresária Limitada' } -getLegalNature('0000'); // null +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) ``` -## generatePhone +### differenceInBusinessDays -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. +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 { generatePhone } from '@brazilian-utils/brazilian-utils'; +import { differenceInBusinessDays } from '@brazilian-utils/brazilian-utils'; -generatePhone(); // '11912345678' ou '1131234567' -generatePhone('mobile'); // '11912345678' -generatePhone('landline'); // '1131234567' -generatePhone('service'); // '08001234567' ou '40041234' +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 ``` -## formatLicensePlate +## Passaporte -Formata uma placa. Placas antigas brasileiras (`LLLNNNN`) são retornadas com hífen e placas Mercosul (`LLLNLNN`) permanecem normalizadas. +### isValidPassport -```javascript -import { formatLicensePlate } from '@brazilian-utils/brazilian-utils'; - -formatLicensePlate('abc1234'); // 'ABC-1234' -formatLicensePlate('abc1d23'); // 'ABC1D23' -``` - -## generateLicensePlate - -Gera uma placa aleatória no formato escolhido. +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 { generateLicensePlate } from '@brazilian-utils/brazilian-utils'; +import { isValidPassport } from '@brazilian-utils/brazilian-utils'; -generateLicensePlate(); // 'ABC1D23' (Mercosul, o padrão) -generateLicensePlate('LLLNNNN'); // 'ABC1234' +isValidPassport('AB123456'); // true +isValidPassport('ab123456'); // true (case-insensitive) +isValidPassport('AB-123.456'); // true (símbolos são ignorados) +isValidPassport('12345678'); // false ``` -## getFormatLicensePlate +### formatPassport -Detecta o formato normalizado de uma placa. +Formata um número de passaporte brasileiro (maiúsculas, sem símbolos, limitado a 8 caracteres). Uma entrada que não seja `string` retorna uma string vazia. ```javascript -import { getFormatLicensePlate } from '@brazilian-utils/brazilian-utils'; +import { formatPassport } from '@brazilian-utils/brazilian-utils'; -getFormatLicensePlate('ABC-1234'); // 'LLLNNNN' -getFormatLicensePlate('ABC1D23'); // 'LLLNLNN' -getFormatLicensePlate('ABC12D3'); // null (não é uma sequência Mercosul) -getFormatLicensePlate('INVALID'); // null -getFormatLicensePlate('ABC1234EXTRA'); // null (caracteres em excesso) +formatPassport('ab123456'); // 'AB123456' +formatPassport('AB-123.456'); // 'AB123456' ``` -`getFormatLicensePlate` exporta o tipo `LicensePlateFormat` (`"LLLNNNN" | "LLLNLNN"`); `generateLicensePlate` reexporta como `GenerateLicensePlateFormat`. - -## parseLicensePlate +### parsePassport -Remove separadores de uma placa, normaliza para letras maiúsculas e limita o resultado a 7 caracteres. +Remove todos os caracteres não alfanuméricos de um número de passaporte, converte para maiúsculas e limita o resultado a 8 caracteres. Uma entrada que não seja `string` retorna uma string vazia. ```javascript -import { parseLicensePlate } from '@brazilian-utils/brazilian-utils'; +import { parsePassport } from '@brazilian-utils/brazilian-utils'; -parseLicensePlate('abc-1234'); // 'ABC1234' +parsePassport('AB-123.456'); // 'AB123456' +parsePassport(' AB 123 456 '); // 'AB123456' ``` -## convertLicensePlateToMercosul +### generatePassport -Converte uma placa brasileira no formato antigo (`LLLNNNN`) para o formato Mercosul (`LLLNLNN`), seguindo a tabela oficial de conversão: o dígito na 5ª posição vira uma letra (`0` a `9` mapeados para `A` a `J`). Retorna `""` quando o valor não é uma placa válida no formato antigo. +Gera um número de passaporte brasileiro válido aleatoriamente. Usa `Math.random()` internamente, então não é criptograficamente seguro. ```javascript -import { convertLicensePlateToMercosul } from '@brazilian-utils/brazilian-utils'; +import { generatePassport } from '@brazilian-utils/brazilian-utils'; -convertLicensePlateToMercosul('ABC1234'); // 'ABC1C34' -convertLicensePlateToMercosul('abc-1234'); // 'ABC1C34' -convertLicensePlateToMercosul('ABC1D23'); // '' (já está no formato Mercosul) +generatePassport(); // 'RY393097' ``` -## generatePis - -Gera um PIS válido aleatório. - -```javascript -import { generatePis } from '@brazilian-utils/brazilian-utils'; - -generatePis(); // '91077906857' -``` +## CNH -## getMunicipality +### isValidCnh -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`. +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 { getMunicipality } from '@brazilian-utils/brazilian-utils'; - -await getMunicipality({ code: '3550308' }); -// ['São Paulo', 'SP'] - -await getMunicipality({ code: 3550308 }); -// ['São Paulo', 'SP'] - -await getMunicipality({ municipalityName: 'sao paulo', uf: 'sp' }); -// '3550308' - -await getMunicipality({ code: '0000000' }); -// null (código desconhecido) +import { isValidCnh } from '@brazilian-utils/brazilian-utils'; -await getMunicipality({ code: '123' }); -// null (não tem 7 dígitos) +isValidCnh('00000000119'); // true +isValidCnh('000000001-19'); // true (hífen antes dos dígitos verificadores) +isValidCnh('ab00000000119'); // false (letras são rejeitadas) ``` -## 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. - -```javascript -import { getMunicipalities } from '@brazilian-utils/brazilian-utils'; - -// Retorna todos os municípios brasileiros (ordenados por nome). -getMunicipalities(); -// [ -// { code: '5200050', name: 'Abadia de Goiás', stateCode: 'GO' }, -// { code: '3100104', name: 'Abadia dos Dourados', stateCode: 'MG' }, -// { code: '5200100', name: 'Abadiânia', stateCode: 'GO' }, -// { code: '3100203', name: 'Abaeté', stateCode: 'MG' }, -// { code: '1500107', name: 'Abaetetuba', stateCode: 'PA' }, -// ... mais 5566 itens -// ] - -// Retorna todos os municípios do estado de São Paulo. -getMunicipalities('SP'); -// [ -// { code: '3500105', name: 'Adamantina', stateCode: 'SP' }, -// { code: '3500204', name: 'Adolfo', stateCode: 'SP' }, -// { code: '3500303', name: 'Aguaí', stateCode: 'SP' }, -// { code: '3500402', name: 'Águas da Prata', stateCode: 'SP' }, -// { code: '3500501', name: 'Águas de Lindóia', stateCode: 'SP' }, -// ... mais 640 itens -// ] +### formatCnh -getMunicipalities('ZZ'); // [] +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'; + +formatCnh('02650306461'); // 026503064-61 +formatCnh('2650306461', { pad: true }); // 026503064-61 ``` -`getMunicipalities` embute todos os 5571 municípios do IBGE e seus códigos, então carrega o mesmo custo de tamanho de pacote que `getCities`. Veja [Tamanho do bundle](getting-started.md#tamanho-do-bundle) para saber como carregá-lo sob demanda via `@brazilian-utils/brazilian-utils/get-municipalities` em vez do import da raiz. +### parseCnh -## getMunicipalityByCode +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. -Busca um município brasileiro pelo código IBGE de 7 dígitos. Aceita o código como string ou número, removendo qualquer caractere não numérico antes de comparar; um código informado como número precisa ser um inteiro não negativo, então `-3550308` e `355030.8` retornam `null` em vez de serem lidos como `3550308`. Retorna `{ code, name, stateCode }`, um objeto novo, ou `null` quando o código não tem 7 dígitos ou não corresponde a nenhum município conhecido. +```javascript +import { parseCnh } from '@brazilian-utils/brazilian-utils'; + +parseCnh('026503064-61'); // '02650306461' +``` + +### generateCnh + +Gera uma CNH válida aleatória. Usa `Math.random()` internamente, então não é criptograficamente seguro. ```javascript -import { getMunicipalityByCode } from '@brazilian-utils/brazilian-utils'; +import { generateCnh } from '@brazilian-utils/brazilian-utils'; -getMunicipalityByCode('3550308'); -// { code: '3550308', name: 'São Paulo', stateCode: 'SP' } +generateCnh(); // '02650306461' +``` -getMunicipalityByCode(3550308); -// { code: '3550308', name: 'São Paulo', stateCode: 'SP' } +## Natureza jurídica -getMunicipalityByCode('0000000'); // null (código desconhecido) -getMunicipalityByCode('123'); // null (não tem 7 dígitos) +### isValidLegalNature + +Valida se um código de natureza jurídica existe na lista oficial. A tabela segue a "Natureza Jurídica 2021" do IBGE/CONCLA: os 92 códigos em vigor mais os 8 que uma revisão anterior da tabela extinguiu, mantidos porque continuam aparecendo em registros feitos enquanto valiam. Use `getLegalNature` para distinguir os dois: um código extinto volta com `legacy: true` e o `currentCode` a que corresponde hoje. Somente os caracteres de máscara usuais (hífens, pontos, espaços) são tolerados ao redor dos 4 dígitos, então `'2062a'` é rejeitado em vez de ser lido como `'2062'`. + +```javascript +import { isValidLegalNature } from '@brazilian-utils/brazilian-utils'; + +isValidLegalNature('2062'); // true +isValidLegalNature('2208'); // true (extinto por uma revisão anterior, ainda aceito) +isValidLegalNature('9999'); // false ``` -## isHoliday +### formatLegalNature -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. +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 { isHoliday } from '@brazilian-utils/brazilian-utils'; +import { formatLegalNature } from '@brazilian-utils/brazilian-utils'; -isHoliday({ targetDate: new Date(2024, 0, 1) }); // true -isHoliday({ targetDate: new Date(2024, 6, 9), stateCode: 'SP' }); // true -isHoliday(); // false +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) ``` -## isBusinessDay +### parseLegalNature -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`. +Remove a formatação da natureza jurídica, mantém apenas os dígitos e limita o resultado a 4 dígitos. ```javascript -import { isBusinessDay } from '@brazilian-utils/brazilian-utils'; +import { parseLegalNature } from '@brazilian-utils/brazilian-utils'; -isBusinessDay(new Date(2024, 0, 2)); // true (terça-feira, não é feriado) -isBusinessDay(new Date(2024, 0, 1)); // false (Ano novo) -isBusinessDay(new Date(2024, 0, 6)); // false (sábado) -isBusinessDay(new Date(2024, 1, 13)); // false (Carnaval, feriado opcional, conta por padrão) -isBusinessDay(new Date(2024, 1, 13), { includeOptional: false }); // true -isBusinessDay(new Date(2024, 6, 9), { stateCode: 'SP' }); // false (Revolução Constitucionalista) -isBusinessDay(new Date(2024, 6, 9)); // true (feriado estadual ignorado sem stateCode) -isBusinessDay(new Date('not a date')); // false +parseLegalNature('206-2'); // '2062' ``` -## addBusinessDays +### generateLegalNature -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`. +Gera um código de natureza jurídica válido aleatório. Apenas os 92 códigos em vigor são sorteados, nunca um dos 8 que uma revisão anterior extinguiu. Usa `Math.random()` internamente, então não é criptograficamente seguro. ```javascript -import { addBusinessDays } from '@brazilian-utils/brazilian-utils'; +import { generateLegalNature } 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) +generateLegalNature(); // '2062' ``` -## differenceInBusinessDays +### getLegalNature + +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. -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`. +Um código que uma revisão anterior da tabela extinguiu continua sendo encontrado, porque segue aparecendo em registros feitos enquanto valia, e volta com `legacy: true` e o `currentCode` a que corresponde hoje, conforme as planilhas de correspondência do CONCLA. Os 92 códigos em vigor têm `legacy: false` e nenhum `currentCode`. + +| Código extinto | Descrição | Corresponde a | +| --- | --- | --- | +| `2076` | Sociedade Empresária em Nome Coletivo | `2070`, o código para o qual a revisão 2003.1 o renumerou, mesma denominação | +| `2100` | Sociedade Mercantil de Capital e Indústria | nenhum, marcado como "categoria extinta" na correspondência 2003.1 x 2009 | +| `2208` | Entidade Binacional Itaipu | `2275` Empresa Binacional | +| `3042` | Organização Social | `3069` Fundação Privada; a revisão de 2014 criou depois o `3301` Organização Social (OS), onde uma entidade assim qualificada é classificada hoje | +| `3050` | Organização da Sociedade Civil de Interesse Público (Oscip) | nenhum, uma Oscip é classificada pela forma que assume (`3999` ou `3069`) | +| `3093` | Unidade Executora (Programa Dinheiro Direto na Escola) | `3999` Associação Privada | +| `3123` | Partido Político | nenhum, a revisão de 2014 o desdobrou em `3255`, `3263` e `3271` | +| `5002` | Organização Internacional e Outras Instituições Extraterritoriais | `5010` Organização Internacional, o código em que foi aberto junto com `5029` e `5037` | ```javascript -import { differenceInBusinessDays } from '@brazilian-utils/brazilian-utils'; +import { getLegalNature } 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 +getLegalNature('2062'); +// { +// code: '2062', +// description: 'Sociedade Empresária Limitada', +// category: { code: '2', description: 'Entidades Empresariais' }, +// legacy: false, +// } +getLegalNature('2208'); +// { +// code: '2208', +// description: 'Entidade Binacional Itaipu', +// category: { code: '2', description: 'Entidades Empresariais' }, +// legacy: true, +// currentCode: '2275', +// } +getLegalNature('3123')?.currentCode; // null (extinto sem sucessor) +getLegalNature('206-2')?.code; // '2062' +getLegalNature(206.2)?.category.description; // 'Entidades Empresariais' +getLegalNature('0000'); // null ``` -## convertDateToWords +### getLegalNatures -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. +Retorna o mapa de naturezas jurídicas indexado pelo código. Por padrão apenas os 92 códigos da tabela CONCLA 2021, os em vigor, são listados; passe `{ includeLegacy: true }` (`GetLegalNaturesParams`) para somar os 8 que uma revisão anterior da tabela extinguiu. ```javascript -import { convertDateToWords } from '@brazilian-utils/brazilian-utils'; +import { getLegalNatures } 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" -convertDateToWords('10/05/1999'); // "dez de maio de mil novecentos e noventa e nove" -convertDateToWords('31/04/2024'); // "" (abril tem 30 dias) -convertDateToWords('invalid'); // "" -convertDateToWords('29/02/1900'); // "" (1900 não é bissexto) +const legalNatures = getLegalNatures(); + +legalNatures['2062']; // 'Sociedade Empresária Limitada' +Object.keys(legalNatures).length; // 92 +legalNatures['2208']; // undefined (extinto por uma revisão anterior) +getLegalNatures({ includeLegacy: true })['2208']; // 'Entidade Binacional Itaipu' ``` -## formatVoterId +### getLegalNaturesByCategory -Formata um título de eleitor. Usa por padrão o agrupamento de 12 dígitos `0000 0000 00 00`; o agrupamento de 13 dígitos `0000 0000 0 00 00` só é usado quando o valor sanitizado tem mais de 12 dígitos **e** o código de unidade federativa (o 10º e o 11º dígitos) é `01` (São Paulo) ou `02` (Minas Gerais), os dois estados cujos títulos podem ter um número sequencial de 9 dígitos. +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 `[]`. Por padrão apenas os códigos em vigor são listados; passe `{ includeLegacy: true }` (`GetLegalNaturesByCategoryOptions`) para somar os códigos extintos da categoria, na ordem dos códigos. ```javascript -import { formatVoterId } from '@brazilian-utils/brazilian-utils'; +import { getLegalNaturesByCategory } from '@brazilian-utils/brazilian-utils'; -formatVoterId('123456780175'); // '1234 5678 01 75' -formatVoterId('1234567880191'); // '1234 5678 8 01 91' (título de 13 dígitos SP/MG) +getLegalNaturesByCategory('4')[0]; +// { +// code: '4014', +// description: 'Empresa Individual Imobiliária', +// category: { code: '4', description: 'Pessoas Físicas' }, +// legacy: false, +// } +getLegalNaturesByCategory(4).length; // 6 +getLegalNaturesByCategory('2').length; // 30 +getLegalNaturesByCategory('2', { includeLegacy: true }).length; // 33 +getLegalNaturesByCategory('9'); // [] ``` -## isValidVoterId +## Título de eleitor -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`). +### 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`). 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'; @@ -1489,19 +1651,18 @@ const voterId = generateVoterId('SP'); isValidVoterId(voterId); // true ``` -## generateVoterId +### formatVoterId -Gera um título de eleitor válido aleatório. Você pode opcionalmente informar a UF; uma UF desconhecida usa `"ZZ"` (título emitido no exterior) em vez de lançar erro. Usa `Math.random()` internamente, então não é criptograficamente seguro. +Formata um título de eleitor. Usa por padrão o agrupamento de 12 dígitos `0000 0000 00 00`; o agrupamento de 13 dígitos `0000 0000 0 00 00` só é usado quando o valor sanitizado tem mais de 12 dígitos **e** o código de unidade federativa (o 10º e o 11º dígitos) é `01` (São Paulo) ou `02` (Minas Gerais), os dois estados cujos títulos podem ter um número sequencial de 9 dígitos. ```javascript -import { generateVoterId } from '@brazilian-utils/brazilian-utils'; +import { formatVoterId } from '@brazilian-utils/brazilian-utils'; -generateVoterId(); // título de eleitor aleatório válido (exterior, "ZZ") -generateVoterId('SP'); // título de eleitor aleatório válido de São Paulo -generateVoterId('XX'); // usa "ZZ" em vez de lançar erro +formatVoterId('123456780175'); // '1234 5678 01 75' +formatVoterId('1234567880191'); // '1234 5678 8 01 91' (título de 13 dígitos SP/MG) ``` -## parseVoterId +### parseVoterId Remove a formatação do título de eleitor, mantém apenas os dígitos e limita o resultado a 12 dígitos (13 quando os dígitos da UF identificam São Paulo ou Minas Gerais). @@ -1512,36 +1673,65 @@ parseVoterId('1234 5678 01 75'); // '123456780175' parseVoterId('1234 5678 8 01 91'); // '1234567880191' (título de 13 dígitos SP/MG) ``` -## isValidCns +### generateVoterId + +Gera um título de eleitor válido aleatório. Você pode opcionalmente informar a UF; uma UF desconhecida usa `"ZZ"` (título emitido no exterior) em vez de lançar erro. Usa `Math.random()` internamente, então não é criptograficamente seguro. + +```javascript +import { generateVoterId } from '@brazilian-utils/brazilian-utils'; + +generateVoterId(); // título de eleitor aleatório válido (exterior, "ZZ") +generateVoterId('SP'); // título de eleitor aleatório válido de São Paulo +generateVoterId('XX'); // usa "ZZ" em vez de lançar erro +``` + +## CNS + +### 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 +### 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' ``` -## isValidCertidao +### 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' +``` + +## Certidão + +### 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 +1745,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 ``` -## parseCertidao +### 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 +1790,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 +1808,11 @@ 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 -``` +## CEI, CNO e CAEPF -## isValidCei +### 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'; @@ -1620,9 +1824,9 @@ isValidCei('24.985.96743/68'); // false (dígito verificador inválido) isValidCei('000000000000'); // false (dígitos repetidos) ``` -## formatCei +### 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 ``` -## isValidCno +### 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'; @@ -1646,9 +1860,9 @@ isValidCno('110840168063'); // false (dígito verificador inválido) isValidCno('000000000000'); // false (dígitos repetidos) ``` -## formatCno +### 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 ``` -## isValidCaepf +### 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 +### 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,35 +1909,21 @@ formatCaepf(41142260000101); // 411.422.600/001-01 formatCaepf('184', { pad: true }); // 000.000.000/001-84 ``` -## isValidRegistroProfissional +### parseCaepf -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). +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 { isValidRegistroProfissional } from '@brazilian-utils/brazilian-utils'; +import { parseCaepf } 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 +parseCaepf('293.118.610/001-84'); // '29311861000184' ``` -## 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. - -```javascript -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('1HGCM8263IA004352'); // false (contém a letra excluída I) -``` +## Códigos de classificação (CBO, CNAE, NCM, CFOP, CST, CSOSN) -## isValidCbo +### 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,145 +1931,273 @@ 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 +### 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 +### 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) ``` -## formatCnae +### 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 +### 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 +### 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 +### 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 ``` -## isValidCfop +### parseNcm -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. +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. 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 +### 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 +### isValidCst Valida um código de CST (Código de Situação Tributária) para um tributo. Informe o tributo em `options.tax`: | 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 +### 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) +``` + +## Texto + +### capitalize + +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('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 (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) ``` -## removeAccents +### removeAccents Remove marcas diacríticas (acentos, tils, cedilhas) de uma string, decompondo cada caractere acentuado em sua letra base mais as marcas de combinação (Unicode NFD) e descartando essas marcas. @@ -1871,3 +2210,71 @@ removeAccents('Ceará'); // 'Ceara' removeAccents('Açaí'); // 'Acai' removeAccents(''); // '' ``` + +## 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`). 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. A inscrição e a UF vão juntas num único objeto, tipado como `IsValidIeParams`; a forma da 2.3.0, `isValidIe(stateCode, ie)`, continua funcionando e está deprecada. + +```javascript +import { isValidIe } from '@brazilian-utils/brazilian-utils'; + +isValidIe({ value: '0187634580933', stateCode: 'AC' }); // false +isValidIe({ value: '109161793', stateCode: 'go' }); // true (case-insensitive) +``` + +## isValidEmail + +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'; + +isValidEmail('john.doe@hotmail.com'); // true +``` + +## 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ç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'; + +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) +``` + +## isValidRegistroProfissional + +Verifica a estrutura de um número de registro/inscrição profissional. Recebe um único objeto, tipado como `IsValidRegistroProfissionalParams`, 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({ 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º 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'; + +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) +``` diff --git a/docs/utilities.md b/docs/utilities.md index 44d56ce73..8cd2535ed 100644 --- a/docs/utilities.md +++ b/docs/utilities.md @@ -2,9 +2,9 @@ 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. +## CPF -## isValidCpf +### isValidCpf Check if CPF is valid. Accepts the usual mask characters and whitespace between/around groups. @@ -15,9 +15,9 @@ isValidCpf('155151475'); // false isValidCpf('111 444 777 35'); // true (whitespace mask) ``` -## formatCpf +### 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'; @@ -27,7 +27,7 @@ formatCpf('746506880', { pad: true }); // 007.465.068-80 formatCpf('12345678909', { obfuscate: true }); // ***.456.789-** ``` -## parseCpf +### parseCpf Remove CPF formatting, keep only digits, and cap the result to 11 digits. @@ -37,9 +37,9 @@ import { parseCpf } from '@brazilian-utils/brazilian-utils'; parseCpf('746.506.880-00'); // 74650688000 ``` -## generateCpf +### 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' @@ -48,9 +48,11 @@ generateCpf(); generateCpf('SP'); // the 9th digit is 8, the SP região fiscal code ``` -## isValidCnpj +## CNPJ -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`. +### isValidCnpj + +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'; @@ -59,9 +61,9 @@ isValidCnpj('15515147234255'); // false isValidCnpj('q0slfmbd7vx439', { version: 2 }); // true (lowercase alphanumeric) ``` -## formatCnpj +### 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'; @@ -72,9 +74,9 @@ formatCnpj('12OUT345000199', { version: 2 }); // 12.OUT.345/0001-99 formatCnpj('12345678000195', { obfuscate: true }); // **.345.678/0001-** ``` -## parseCnpj +### 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'; @@ -83,9 +85,24 @@ parseCnpj('24.522.200/0001-74'); // 24522200000174 parseCnpj('12.OUT.345/0001-99', { version: 2 }); // 12OUT345000199 ``` -## isValidCep +### generateCnpj + +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 `GenerateCnpjParams` 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' +``` + +## CEP and address -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. +### 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, 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'; @@ -99,20 +116,94 @@ isValidCep('9250000A'); // false (letters are rejected) isValidCep('12345'); // false (invalid length) ``` -## generateCnpj +### formatCep -Generate a valid random CNPJ. +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 { generateCnpj } from '@brazilian-utils/brazilian-utils' +import { formatCep } from '@brazilian-utils/brazilian-utils'; -generateCnpj(); -generateCnpj(2); // alphanumeric CNPJ, e.g. 'Q0SLFMBD7VX439' +formatCep('92500000'); // 92500-000 +formatCep('9250000', { pad: true }); // 09250-000 +``` + +### parseCep + +Remove CEP formatting, keep only digits, and cap the result to 8 digits. + +```javascript +import { parseCep } from '@brazilian-utils/brazilian-utils'; + +parseCep('92500-000'); // 92500000 +``` + +### generateCep + +Generate a random CEP. Uses `Math.random()` internally, so it is not cryptographically secure. + +```javascript +import { generateCep } from '@brazilian-utils/brazilian-utils'; + +generateCep(); // '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`. 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'; + +// Using the default providers (['viacep', 'brasilapi']) +const address = await getAddressInfoByCep('01310100'); +// { cep: '01310100', state: 'SP', city: 'São Paulo', neighborhood: 'Bela Vista', street: 'Avenida Paulista' } + +// Using specific providers +const addressFromProviders = await getAddressInfoByCep('01310-100', { + providers: ['viacep', 'brasilapi'] +}); + +// Using number input (will be padded automatically) +const addressFromNumber = await getAddressInfoByCep(1310100); +``` + +### getCepInfoByAddress + +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: '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' +// } +// ] ``` -## isValidBoleto +## Boleto + +### 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'; @@ -121,9 +212,9 @@ isValidBoleto('00190000090114971860168524522114675860000102656'); // true isValidBoleto('846100000005246100291102005460339004695895061080'); // true (boleto de arrecadação) ``` -## formatBoleto +### 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'; @@ -134,7 +225,7 @@ formatBoleto('846100000005246100291102005460339004695895061080'); // 84610000000 formatBoleto('84610000000246100291100054603390069589506108'); // 84610.00000 02461.002911 00054.603390 0 69589506108 (44 digit arrecadação barcode keeps the bancária mask) ``` -## parseBoleto +### parseBoleto Remove boleto formatting, keep only digits, and cap the result to 47 digits (48 for boleto de arrecadação). @@ -144,9 +235,9 @@ import { parseBoleto } from '@brazilian-utils/brazilian-utils'; parseBoleto('00190.00009 01149.718601 68524.522114 6 75860000102656'); // 00190000090114971860168524522114675860000102656 ``` -## generateBoleto +### 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 `GenerateBoletoParams`) 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'; @@ -155,9 +246,9 @@ generateBoleto(); // "00190000090114971860168524522114675860000102656" generateBoleto({ type: 'arrecadacao' }); // "846100000005246100291102005460339004695895061080" ``` -## getBoletoInfo +### 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,9 +263,13 @@ getBoletoInfo('00190000090114971860168524522114675860000102656', { getBoletoInfo('846100000005246100291102005460339004695895061080'); // { amount: 2461, expirationDate: null, bankCode: '', type: 'arrecadacao', segment: 4, value: 24.61, hasEffectiveValue: true } + +getBoletoInfo('invalid'); // null ``` -## isValidPixKey +## Pix + +### 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. The manual registers a "número de telefone celular", so a landline is not a valid phone key. `options.accept` (typed as `IsValidPixKeyOptions`) restricts which kinds of key are accepted; it defaults to all of them, and `[]` rejects everything. Exports the `PixKeyType` type. @@ -190,26 +285,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 +### 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 +317,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 +### 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 `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. 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'; @@ -267,72 +363,97 @@ generatePixPayload({ generatePixPayload({ merchantName: 'Fulano', merchantCity: 'Brasília' }); // null (neither key nor url) ``` -## isValidNfeKey +## NF-e key + +### isValidNfeKey + +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. -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. +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 +### 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 +### 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', -// 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' ``` -## isValidEmail +### getNfeKeyInfo -Check if email is valid. +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 { isValidEmail } from '@brazilian-utils/brazilian-utils'; +import { getNfeKeyInfo } from '@brazilian-utils/brazilian-utils'; -isValidEmail('john.doe@hotmail.com'); // true +getNfeKeyInfo('35170458716523000119550010000000121000123458'); +// { stateCode: 'SP', year: 2017, month: 4, taxId: '58716523000119', model: '55', +// series: 1, number: 12, emissionType: 1, code: '00012345', checkDigit: 8 } + +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 ``` -## isValidPhone +## Phone + +### 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 isValidPhone('11900000000', { accept: [] }); // false ``` -## formatPhone +### 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 +461,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 @@ -348,7 +471,7 @@ formatPhone('+5511987654321', { mask: 'auto' }); // +55 11 98765-4321 ("auto" de formatPhone('11900000000'); // 11900-0000 (BEWARE: default "sn" truncates a DDD-prefixed number) ``` -## parsePhone +### parsePhone Remove phone formatting, keep only digits, and cap the result to 11 digits. A Brazilian country code is stripped first, but only when the digits left behind are exactly 10 or 11 long, i.e. a plausible national number. The rule is length-based, not sign-based, so a number from area code 55 is not mistaken for a country code. @@ -361,19 +484,34 @@ parsePhone('5511987654321'); // 11987654321 parsePhone('55987654321'); // 55987654321 (area code 55, not mistaken for the +55 country code) ``` -## isValidMobilePhone +### 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. A generated mobile number always starts with 9, so it passes both `isValidMobilePhone` numbering rules. + +```javascript +import { generatePhone } from '@brazilian-utils/brazilian-utils'; + +generatePhone(); // '11912345678' or '1131234567' +generatePhone('mobile'); // '11912345678' +generatePhone('landline'); // '1131234567' +generatePhone('service'); // '08001234567' or '40041234' +``` + +### 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 +### isValidLandlinePhone Check if landline phone number is valid. @@ -383,9 +521,9 @@ import { isValidLandlinePhone } from '@brazilian-utils/brazilian-utils'; isValidLandlinePhone('1130000000'); // true ``` -## isValidServicePhone +### 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'; @@ -396,30 +534,30 @@ isValidServicePhone('190'); // true isValidServicePhone('11987654321'); // false (geographic number) ``` -## getAreaCodeInfo +### 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. 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 getAreaCodeInfo(1.1); // null ``` -## getAreaCodesByState +### getAreaCodesByState Get every DDD (area code) that serves a given Brazilian state, under the Anatel Plano Geral de Numeração. The match is case-insensitive and the result is sorted in ascending order. @@ -436,7 +574,9 @@ getAreaCodesByState('SC'); // [42, 47, 48, 49] getAreaCodesByState('XX'); // [] ``` -## isValidLicensePlate +## License plate + +### isValidLicensePlate Check if license plate is valid. Supports the old Brazilian format (ABC-1234) and the Mercosul format (ABC1D23), the single sequence Resolução CONTRAN nº 969/2022 defines for every vehicle, motorcycles included. @@ -451,110 +591,167 @@ isValidLicensePlate('ABC12D3'); // false (not a Mercosul sequence) isValidLicensePlate('ABC1234EXTRA'); // false (too many characters) ``` -## isValidRenavam +### formatLicensePlate -Check if RENAVAM (Registro Nacional de Veículos Automotores) is valid. Supports both the old format (9 digits) and the new format (11 digits). +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 { isValidRenavam } from '@brazilian-utils/brazilian-utils'; +import { formatLicensePlate } from '@brazilian-utils/brazilian-utils'; -isValidRenavam('639884962'); // true (9 digits, old format) -isValidRenavam('00639884962'); // true (11 digits, new format) -isValidRenavam('12345678901'); // false (invalid checksum) +formatLicensePlate('abc1234'); // 'ABC-1234' +formatLicensePlate('abc1d23'); // 'ABC1D23' ``` -## isValidPis +### parseLicensePlate -Check if PIS is valid. Accepts the usual mask characters (`.`, `-`, `/`, `(`, `)`, `,`, `*`) and whitespace. +Remove separators from a license plate, normalize it to uppercase, and cap it to 7 characters. ```javascript -import { isValidPis } from '@brazilian-utils/brazilian-utils'; +import { parseLicensePlate } from '@brazilian-utils/brazilian-utils'; -isValidPis('12056412547'); // false +parseLicensePlate('abc-1234'); // 'ABC1234' ``` -## formatPis +### generateLicensePlate -Format PIS number. +Generate a random license plate in the chosen format. Uses `Math.random()` internally, so it is not cryptographically secure. ```javascript -import { formatPis } from '@brazilian-utils/brazilian-utils'; +import { generateLicensePlate } from '@brazilian-utils/brazilian-utils'; -formatPis('12345678901'); // 123.45678.90-1 -formatPis('123456789', { pad: true }); // 001.23456.78-9 +generateLicensePlate(); // 'ABC1D23' (Mercosul, the default) +generateLicensePlate('LLLNNNN'); // 'ABC1234' +generateLicensePlate('LLLNNLN'); // 'ABC1D23' (a format outside the two in circulation falls back to the default) ``` -## parsePis +A `format` outside the two supported literals falls back to the Mercosul default, the way every other generator in this package treats an option it does not know, so the result is always a plate `isValidLicensePlate` accepts. That default sequence is `LLLNLNN`, from Resolução CONTRAN nº 969/2022, Anexo I item 1.2, the single sequence the resolution defines for every vehicle, motorcycles included. (2.3.0 used an unknown string verbatim, so `generateLicensePlate('LLLNNLN')` produced the withdrawn motorcycle sequence and `generateLicensePlate('bogus')` five digits; neither is a plate.) -Remove PIS formatting, keep only digits, and cap the result to 11 digits. +### getFormatLicensePlate + +Detect the normalized format of a license plate. ```javascript -import { parsePis } from '@brazilian-utils/brazilian-utils'; +import { getFormatLicensePlate } from '@brazilian-utils/brazilian-utils'; -parsePis('123.45678.90-1'); // 12345678901 +getFormatLicensePlate('ABC-1234'); // 'LLLNNNN' +getFormatLicensePlate('ABC1D23'); // 'LLLNLNN' +getFormatLicensePlate('ABC12D3'); // null (not a Mercosul sequence) +getFormatLicensePlate('INVALID'); // null +getFormatLicensePlate('ABC1234EXTRA'); // null (too many characters) ``` -## formatCep +`getFormatLicensePlate` exports the `LicensePlateFormat` type (`"LLLNNNN" | "LLLNLNN"`); `generateLicensePlate` re-exports it as `GenerateLicensePlateFormat`. + +### convertLicensePlateToMercosul -Format CEP ([brazilian postal code](https://en.wikipedia.org/wiki/C%C3%B3digo_de_Endere%C3%A7amento_Postal)). +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`). Returns `""` when the value is not a valid old format license plate. ```javascript -import { formatCep } from '@brazilian-utils/brazilian-utils'; +import { convertLicensePlateToMercosul } from '@brazilian-utils/brazilian-utils'; -formatCep('92500000'); // 92500-000 +convertLicensePlateToMercosul('ABC1234'); // 'ABC1C34' +convertLicensePlateToMercosul('abc-1234'); // 'ABC1C34' +convertLicensePlateToMercosul('ABC1D23'); // '' (already Mercosul) ``` -## parseCep +## RENAVAM -Remove CEP formatting, keep only digits, and cap the result to 8 digits. +### 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). 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 { parseCep } from '@brazilian-utils/brazilian-utils'; +import { isValidRenavam } from '@brazilian-utils/brazilian-utils'; -parseCep('92500-000'); // 92500000 +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) ``` -## getAddressInfoByCep +### generateRenavam -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`. +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 { getAddressInfoByCep } from '@brazilian-utils/brazilian-utils'; +import { generateRenavam } from '@brazilian-utils/brazilian-utils'; -// Using the default providers (['viacep', 'brasilapi']) -const address = await getAddressInfoByCep('01310100'); -// { cep: '01310100', state: 'SP', city: 'São Paulo', neighborhood: 'Bela Vista', street: 'Avenida Paulista' } +generateRenavam(); // '12345678900' +``` -// Using specific providers -const address = await getAddressInfoByCep('01310-100', { - providers: ['viacep', 'brasilapi'] -}); +## PIS -// Using number input (will be padded automatically) -const address = await getAddressInfoByCep(1310100); +### isValidPis + +Check if PIS is valid. Accepts the usual mask characters (`.`, `-`, `/`, `(`, `)`, `,`, `*`) and whitespace. + +```javascript +import { isValidPis } from '@brazilian-utils/brazilian-utils'; + +isValidPis('12056412547'); // false +``` + +### formatPis + +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'; + +formatPis('12345678901'); // 123.45678.90-1 +formatPis('123456789', { pad: true }); // 001.23456.78-9 +``` + +### parsePis + +Remove PIS formatting, keep only digits, and cap the result to 11 digits. + +```javascript +import { parsePis } from '@brazilian-utils/brazilian-utils'; + +parsePis('123.45678.90-1'); // 12345678901 +``` + +### generatePis + +Generate a valid random PIS. Uses `Math.random()` internally, so it is not cryptographically secure. + +```javascript +import { generatePis } from '@brazilian-utils/brazilian-utils'; + +generatePis(); // '91077906857' ``` -## isValidProcessoJuridico +## Processo jurídico + +### 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 +### 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 +### parseProcessoJuridico Remove processo jurídico formatting, keep only digits, and cap the result to 20 digits. Both the current CNJ mask (`NNNNNNN-DD.AAAA.J.TR.OOOO`) and the older one are accepted, since only the digits are kept. @@ -564,18 +761,22 @@ import { parseProcessoJuridico } from '@brazilian-utils/brazilian-utils'; parseProcessoJuridico('0002080-25.2012.5.15.0049'); // 00020802520125150049 ``` -## isValidIe +### generateProcessoJuridico -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`). +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 { isValidIe } from '@brazilian-utils/brazilian-utils'; +import { generateProcessoJuridico } from '@brazilian-utils/brazilian-utils'; -isValidIe('AC', '0187634580933'); // false -isValidIe('go', '109161793'); // true (case-insensitive) +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) ``` -## isValidBankAccount +## Bank accounts and banks + +### isValidBankAccount Check if a Brazilian bank account is valid. The `bankCode` must belong to the Banco Central do Brasil STR participants list (the same dataset used by `getBankByCode`), so an unassigned code such as `'999'` is always invalid. Banks are then validated in one of three ways: by their published check digit algorithm, by structure only (bank exists and the agency/account match the documented digit lengths, for banks that publish no check digit rule) or by a generic mod10/mod11 check, which stays the fallback for every other listed bank. @@ -583,11 +784,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 +798,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. @@ -680,7 +879,7 @@ isValidBankAccount({ }); // true (Banco ABC Brasil, generic mod10 fallback) ``` -## getBanks +### getBanks Get every Brazilian bank with a compensation code (COMPE), published by Banco Central do Brasil in the [STR participants list](https://www.bcb.gov.br/content/estabilidadefinanceira/str1/ParticipantesSTR.csv). Each bank (typed as `Bank`) has a `code` (COMPE, 3 digits), an `ispb` (Identificador do Sistema de Pagamentos Brasileiro, 8 digits) and a `name`. Each call returns a fresh array of fresh objects, so mutating the result never affects subsequent calls. @@ -696,7 +895,7 @@ getBanks(); // ] ``` -## getBankByCode +### getBankByCode Look a Brazilian bank up by its compensation code (COMPE), published by Banco Central do Brasil in the [STR participants list](https://www.bcb.gov.br/content/estabilidadefinanceira/str1/ParticipantesSTR.csv). 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 code. @@ -708,9 +907,9 @@ getBankByCode(1); // { code: '001', ispb: '00000000', name: 'Banco do Brasil S.A getBankByCode('999'); // null ``` -## getBankByIspb +### 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'; @@ -720,23 +919,26 @@ getBankByIspb('60701190'); // { code: '341', ispb: '60701190', name: 'ITAÚ UNIB getBankByIspb('99999999'); // null ``` -## isValidIban +## IBAN + +### 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 +### 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 +946,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 +### 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,45 +978,15 @@ 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. - -```javascript -import { isValidCreditCard } from '@brazilian-utils/brazilian-utils'; - -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('4111111111111112'); // false (bad check digit) -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`. - -```javascript -import { capitalize } from '@brazilian-utils/brazilian-utils'; - -capitalize('josé e maria'); // José e Maria -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(' josé maria '); // José Maria (every run of whitespace, tabs and newlines included, collapses into one space) -``` +## Currency, numbers and dates in words -## formatCurrency +### 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'; @@ -819,9 +1002,9 @@ formatCurrency('-10.5'); // -10,50 (a leading "-" is preserved) formatCurrency(Number.NaN); // "" (non finite numbers format as an empty string) ``` -## parseCurrency +### 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'; @@ -837,9 +1020,9 @@ parseCurrency('R$ 1,001', { precision: 3 }); // 1.001 parseCurrency(''); // 0 ``` -## convertNumberToWords +### 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,28 +1032,48 @@ 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 +### 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 +### 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. 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'; + +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('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" +convertDateToWords('10/05/1999'); // "dez de maio de mil novecentos e noventa e nove" +convertDateToWords('31/04/2024'); // "" (April has 30 days) +convertDateToWords('invalid'); // "" +convertDateToWords('29/02/1900'); // "" (1900 is not a leap year) +``` + +## States and municipalities + +### 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`). The list is sorted by name with `localeCompare` in the "pt-BR" locale, so accented names land where a Brazilian reader expects them: Pará, Paraíba, Paraná and Rio de Janeiro, Rio Grande do Norte, Rio Grande do Sul. Each call returns a fresh array of fresh objects, so mutating the result never affects subsequent calls. Exports the `State`, `StateCode` and `StateName` types. `State` is a discriminated union with one member per state, so the fields of a state are tied to each other: narrowing a `State` by `code` narrows its `name`, `regionCode`, `regionName` and `ibgeCode` too (`Extract['name']` is `'São Paulo'`), and an impossible combination such as `{ code: 'SP', name: 'Acre' }` is not a `State`. @@ -909,9 +1112,9 @@ getStates(); // ] ``` -## getStateByIbgeCode +### 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'; @@ -927,9 +1130,9 @@ getStateByIbgeCode(-35); // null getStateByIbgeCode(3.5); // null ``` -## getStateCodeByName +### 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'; @@ -940,7 +1143,7 @@ getStateCodeByName(' Rio de Janeiro '); // 'RJ' getStateCodeByName('Neverland'); // null ``` -## getStateNameByCode +### getStateNameByCode Get the full name of a Brazilian state given its two-letter code (sigla). The match is case-insensitive and ignores leading/trailing whitespace, so `'sp'`, `'SP'` and `' Sp '` all resolve to `'São Paulo'`. Exports the `StateName` type. @@ -953,7 +1156,7 @@ getStateNameByCode(' Rj '); // 'Rio de Janeiro' getStateNameByCode('ZZ'); // null ``` -## getTimezoneByState +### getTimezoneByState Get the IANA time zone database name (tzdata zone) for a Brazilian state, chosen as the zone of the state capital. The match is case-insensitive and ignores leading/trailing whitespace. Some tzdata zones cover more than one state: `America/Sao_Paulo` also covers DF, GO, MG, ES, RJ, PR, SC and RS besides SP, and `America/Fortaleza` also covers MA, PI, RN and PB besides CE. Pernambuco resolves to `America/Recife`, not `America/Noronha`: Fernando de Noronha is an archipelago district of PE, not a state of its own. @@ -967,519 +1170,478 @@ getTimezoneByState('PE'); // 'America/Recife' getTimezoneByState('ZZ'); // null ``` -## getCities +### getMunicipalities -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 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 { getCities } from '@brazilian-utils/brazilian-utils'; +import { getMunicipalities } from '@brazilian-utils/brazilian-utils'; -// Return all Brazilian cities (sorted alphabetically). -getCities(); +// Return every Brazilian municipality (sorted by name). +getMunicipalities(); // [ -// 'Abadia de Goiás', -// 'Abadia dos Dourados', -// 'Abadiânia', -// 'Abaeté', -// 'Abaetetuba', -// 'Abaiara', -// 'Abaíra', -// 'Abaré', -// 'Abatiá', -// 'Abdon Batista', -// ... 5561 more items +// { code: '5200050', name: 'Abadia de Goiás', stateCode: 'GO' }, +// { code: '3100104', name: 'Abadia dos Dourados', stateCode: 'MG' }, +// { code: '5200100', name: 'Abadiânia', stateCode: 'GO' }, +// { code: '3100203', name: 'Abaeté', stateCode: 'MG' }, +// { code: '1500107', name: 'Abaetetuba', stateCode: 'PA' }, +// ... 5566 more items // ] -// Return all Brazilian cities of the São Paulo state (sorted alphabetically). -getCities('SP'); +// Return every municipality of the São Paulo state. +getMunicipalities('SP'); // [ -// "Adamantina", -// "Adolfo", -// "Aguaí", -// "Águas da Prata", -// "Águas de Lindóia", -// "Águas de Santa Bárbara", -// "Águas de São Pedro", -// "Agudos", -// "Alambari", -// "Alfredo Marcondes", -// ... 635 more items +// { code: '3500105', name: 'Adamantina', stateCode: 'SP' }, +// { code: '3500204', name: 'Adolfo', stateCode: 'SP' }, +// { code: '3500303', name: 'Aguaí', stateCode: 'SP' }, +// { code: '3500402', name: 'Águas da Prata', stateCode: 'SP' }, +// { code: '3500501', name: 'Águas de Lindóia', stateCode: 'SP' }, +// ... 640 more items // ] + +getMunicipalities('ZZ'); // [] ``` -`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. +`getMunicipalities` embeds all 5571 IBGE municipalities and their codes, so it carries the same bundle-size cost as `getCities`. See [Bundle size](getting-started.md#bundle-size) for how to lazy-load it via `@brazilian-utils/brazilian-utils/get-municipalities` instead of the root import. -## getHolidays +### getMunicipalityByCode -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. +Look up a Brazilian municipality by its 7-digit IBGE code. Accepts the code as a string or a number, with any non-digit characters stripped before matching; a code given as a number must be a non-negative integer, so `-3550308` and `355030.8` return `null` instead of being read as `3550308`. Returns `{ code, name, stateCode }`, a fresh object, or `null` when the code is not 7 digits long or does not match any known municipality. ```javascript -import { getHolidays } from '@brazilian-utils/brazilian-utils'; - -// Get all national holidays for 2024 -getHolidays(2024); -// [ -// { name: 'Ano novo', date: Date('2024-01-01'), type: 'national' }, -// { name: 'Carnaval (terça-feira)', date: Date('2024-02-13'), type: 'optional' }, -// { name: 'Sexta-feira Santa', date: Date('2024-03-29'), type: 'national' }, -// { name: 'Páscoa', date: Date('2024-03-31'), type: 'religious' }, -// { name: 'Dia da Consciência Negra', date: Date('2024-11-20'), type: 'national' }, -// // ... more holidays -// ] - -// Get holidays for a specific state -getHolidays({ year: 2024, stateCode: 'SP' }); -// Includes national holidays plus state-specific holidays (e.g., "Revolução Constitucionalista") -``` - -## isValidPassport +import { getMunicipalityByCode } from '@brazilian-utils/brazilian-utils'; -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. +getMunicipalityByCode('3550308'); +// { code: '3550308', name: 'São Paulo', stateCode: 'SP' } -```javascript -import { isValidPassport } from '@brazilian-utils/brazilian-utils'; +getMunicipalityByCode(3550308); +// { code: '3550308', name: 'São Paulo', stateCode: 'SP' } -isValidPassport('AB123456'); // true -isValidPassport('ab123456'); // true (case-insensitive) -isValidPassport('AB-123.456'); // true (symbols are ignored) -isValidPassport('12345678'); // false +getMunicipalityByCode('0000000'); // null (unknown code) +getMunicipalityByCode('123'); // null (not 7 digits) ``` -## formatPassport +### getCities -Format a Brazilian passport number (uppercase, without symbols, capped to 8 characters). A non-string input returns an empty string. +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 { formatPassport } from '@brazilian-utils/brazilian-utils'; - -formatPassport('ab123456'); // 'AB123456' -formatPassport('AB-123.456'); // 'AB123456' -``` - -## generatePassport - -Generate a random valid Brazilian passport number. +import { getCities } from '@brazilian-utils/brazilian-utils'; -```javascript -import { generatePassport } from '@brazilian-utils/brazilian-utils'; +// Return all Brazilian cities (sorted alphabetically). +getCities(); +// [ +// 'Abadia de Goiás', +// 'Abadia dos Dourados', +// 'Abadiânia', +// 'Abaeté', +// 'Abaetetuba', +// 'Abaiara', +// 'Abaíra', +// 'Abaré', +// 'Abatiá', +// 'Abdon Batista', +// ... 5561 more items +// ] -generatePassport(); // 'RY393097' +// Return all Brazilian cities of the São Paulo state (sorted alphabetically). +getCities('SP'); +// [ +// "Adamantina", +// "Adolfo", +// "Aguaí", +// "Águas da Prata", +// "Águas de Lindóia", +// "Águas de Santa Bárbara", +// "Águas de São Pedro", +// "Agudos", +// "Alambari", +// "Alfredo Marcondes", +// ... 635 more items +// ] ``` -## parsePassport - -Remove all non-alphanumeric characters from a passport number, uppercase the result, and cap it to 8 characters. A non-string input returns an empty string. - -```javascript -import { parsePassport } from '@brazilian-utils/brazilian-utils'; +`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. -parsePassport('AB-123.456'); // 'AB123456' -parsePassport(' AB 123 456 '); // 'AB123456' -``` +### getMunicipality -## generateCep - -Generate a random CEP. +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 { generateCep } from '@brazilian-utils/brazilian-utils'; +import { getMunicipality } from '@brazilian-utils/brazilian-utils'; -generateCep(); // '92500000' -``` +await getMunicipality({ code: '3550308' }); +// ['São Paulo', 'SP'] -## formatCnh +await getMunicipality({ code: 3550308 }); +// ['São Paulo', 'SP'] -Format CNH. +await getMunicipality({ municipalityName: 'sao paulo', uf: 'sp' }); +// '3550308' -```javascript -import { formatCnh } from '@brazilian-utils/brazilian-utils'; +await getMunicipality({ code: '0000000' }); +// null (unknown code) -formatCnh('02650306461'); // 026503064-61 -formatCnh('2650306461', { pad: true }); // 026503064-61 +await getMunicipality({ code: '123' }); +// null (not 7 digits) ``` -## isValidCnh +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 `GetMunicipalityParams`) resolves to the union of both. The 2.3.0 names `GetMunicipalityOptions`, `GetMunicipalityByCodeOptions` and `GetMunicipalityByNameOptions` are still exported as deprecated aliases of these. -Check if CNH is valid. +```typescript +import { + getMunicipality, + type GetMunicipalityByCodeParams, + type GetMunicipalityByNameParams, + type GetMunicipalityParams, +} from '@brazilian-utils/brazilian-utils'; -```javascript -import { isValidCnh } from '@brazilian-utils/brazilian-utils'; - -isValidCnh('00000000119'); // true -``` +const byCode: GetMunicipalityByCodeParams = { code: '3550308' }; +const byName: GetMunicipalityByNameParams = { municipalityName: 'sao paulo', uf: 'sp' }; -## generateCnh +await getMunicipality(byCode); +// Promise<[string, string] | null> -Generate a valid random CNH. +await getMunicipality(byName); +// Promise -```javascript -import { generateCnh } from '@brazilian-utils/brazilian-utils'; - -generateCnh(); // '02650306461' +const lookUp = (options: GetMunicipalityParams) => getMunicipality(options); +// (options: GetMunicipalityParams) => Promise<[string, string] | string | null> ``` -## parseCnh +## Holidays and business days -Remove CNH formatting, keep only digits, and cap the result to 11 digits. +### getHolidays -```javascript -import { parseCnh } from '@brazilian-utils/brazilian-utils'; +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, under the same `"Dia da Consciência Negra"` name in MT, RJ, AM and SP, and under `"Dia Estadual da Consciência Negra"` in AP, the name that state's own law uses. Commemorative dates that no law turns into a holiday are not listed: RN's "Dia do Rio Grande do Norte" (7 August, Lei RN nº 7.831/2000) is one, and neither is RO's "Dia dos Evangélicos" (18 June), whose law the STF struck down in ADI 3940. 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 `[]`. -parseCnh('026503064-61'); // '02650306461' -``` +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: -## getCepInfoByAddress +- **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. -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. +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 { getCepInfoByAddress } from '@brazilian-utils/brazilian-utils'; - -const ceps = await getCepInfoByAddress({ - federalUnit: 'SP', - city: 'Sao Paulo', - street: 'Avenida Paulista' -}); +import { getHolidays } from '@brazilian-utils/brazilian-utils'; +// Get all national holidays for 2024 +getHolidays(2024); // [ -// { -// cep: '01310100', -// logradouro: 'Avenida Paulista', -// complemento: 'lado par', -// bairro: 'Bela Vista', -// localidade: 'São Paulo', -// uf: 'SP' -// } +// { name: 'Ano novo', date: Date('2024-01-01'), type: 'national' }, +// { name: 'Carnaval (terça-feira)', date: Date('2024-02-13'), type: 'optional' }, +// { name: 'Sexta-feira Santa', date: Date('2024-03-29'), type: 'national' }, +// { name: 'Páscoa', date: Date('2024-03-31'), type: 'religious' }, +// { name: 'Dia da Consciência Negra', date: Date('2024-11-20'), type: 'national' }, +// // ... more holidays // ] -``` - -## 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. - -```javascript -import { generateProcessoJuridico } from '@brazilian-utils/brazilian-utils'; - -generateProcessoJuridico(); // '89478643020269670326' -generateProcessoJuridico({ year: 2026, court: 5 }); // string | null -generateProcessoJuridico({ year: 10000 }); // null (year out of range) -``` - -## formatLegalNature - -Format a legal nature code. - -```javascript -import { formatLegalNature } from '@brazilian-utils/brazilian-utils'; - -formatLegalNature('2062'); // 206-2 -``` - -## isValidLegalNature -Check if a legal nature code exists in the official list. The table follows IBGE/CONCLA's "Natureza Jurídica 2021": 92 official codes plus 8 legacy codes kept for backwards compatibility. Only the usual mask characters (hyphens, dots, whitespace) are tolerated around the 4 digits, so `'2062a'` is rejected instead of being read as `'2062'`. - -```javascript -import { isValidLegalNature } from '@brazilian-utils/brazilian-utils'; - -isValidLegalNature('2062'); // true -isValidLegalNature('9999'); // false +// Get holidays for a specific state +getHolidays({ year: 2024, stateCode: 'SP' }); +// Includes national holidays plus state-specific holidays (e.g., "Revolução Constitucionalista") ``` -## generateLegalNature +### isHoliday -Generate a random valid legal nature code. +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 { generateLegalNature } from '@brazilian-utils/brazilian-utils'; +import { isHoliday } from '@brazilian-utils/brazilian-utils'; -generateLegalNature(); // '2062' +isHoliday({ targetDate: new Date(2024, 0, 1) }); // true +isHoliday({ targetDate: new Date(2024, 6, 9), stateCode: 'SP' }); // true +isHoliday(); // false ``` -## parseLegalNature +### isBusinessDay -Remove legal nature formatting, keep only digits, and cap the result to 4 digits. +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 { parseLegalNature } from '@brazilian-utils/brazilian-utils'; +import { isBusinessDay } from '@brazilian-utils/brazilian-utils'; -parseLegalNature('206-2'); // '2062' +isBusinessDay(new Date(2024, 0, 2)); // true (Tuesday, not a holiday) +isBusinessDay(new Date(2024, 0, 1)); // false (Ano novo) +isBusinessDay(new Date(2024, 0, 6)); // false (Saturday) +isBusinessDay(new Date(2024, 1, 13)); // false (Carnaval, optional holiday, counts by default) +isBusinessDay(new Date(2024, 1, 13), { includeOptional: false }); // true +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 ``` -## getLegalNatures +### addBusinessDays -Get the legal nature map keyed by code. +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 { getLegalNatures } from '@brazilian-utils/brazilian-utils'; - -const legalNatures = getLegalNatures(); +import { addBusinessDays } from '@brazilian-utils/brazilian-utils'; -legalNatures['2062']; // 'Sociedade Empresária Limitada' +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) ``` -## getLegalNature +### subBusinessDays -Look a legal nature code up in the official IBGE/CONCLA table. +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 { getLegalNature } from '@brazilian-utils/brazilian-utils'; +import { subBusinessDays } from '@brazilian-utils/brazilian-utils'; -getLegalNature('2062'); // { code: '2062', description: 'Sociedade Empresária Limitada' } -getLegalNature('0000'); // null +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) ``` -## generatePhone +### differenceInBusinessDays -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. +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 { generatePhone } from '@brazilian-utils/brazilian-utils'; +import { differenceInBusinessDays } from '@brazilian-utils/brazilian-utils'; -generatePhone(); // '11912345678' or '1131234567' -generatePhone('mobile'); // '11912345678' -generatePhone('landline'); // '1131234567' -generatePhone('service'); // '08001234567' or '40041234' +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 ``` -## formatLicensePlate - -Format a license plate. Old Brazilian plates (`LLLNNNN`) are returned with a hyphen and Mercosul plates (`LLLNLNN`) stay normalized. - -```javascript -import { formatLicensePlate } from '@brazilian-utils/brazilian-utils'; - -formatLicensePlate('abc1234'); // 'ABC-1234' -formatLicensePlate('abc1d23'); // 'ABC1D23' -``` +## Passport -## generateLicensePlate +### isValidPassport -Generate a random license plate in the chosen format. +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 { generateLicensePlate } from '@brazilian-utils/brazilian-utils'; +import { isValidPassport } from '@brazilian-utils/brazilian-utils'; -generateLicensePlate(); // 'ABC1D23' (Mercosul, the default) -generateLicensePlate('LLLNNNN'); // 'ABC1234' +isValidPassport('AB123456'); // true +isValidPassport('ab123456'); // true (case-insensitive) +isValidPassport('AB-123.456'); // true (symbols are ignored) +isValidPassport('12345678'); // false ``` -## getFormatLicensePlate +### formatPassport -Detect the normalized format of a license plate. +Format a Brazilian passport number (uppercase, without symbols, capped to 8 characters). A non-string input returns an empty string. ```javascript -import { getFormatLicensePlate } from '@brazilian-utils/brazilian-utils'; +import { formatPassport } from '@brazilian-utils/brazilian-utils'; -getFormatLicensePlate('ABC-1234'); // 'LLLNNNN' -getFormatLicensePlate('ABC1D23'); // 'LLLNLNN' -getFormatLicensePlate('ABC12D3'); // null (not a Mercosul sequence) -getFormatLicensePlate('INVALID'); // null -getFormatLicensePlate('ABC1234EXTRA'); // null (too many characters) +formatPassport('ab123456'); // 'AB123456' +formatPassport('AB-123.456'); // 'AB123456' ``` -`getFormatLicensePlate` exports the `LicensePlateFormat` type (`"LLLNNNN" | "LLLNLNN"`); `generateLicensePlate` re-exports it as `GenerateLicensePlateFormat`. - -## parseLicensePlate +### parsePassport -Remove separators from a license plate, normalize it to uppercase, and cap it to 7 characters. +Remove all non-alphanumeric characters from a passport number, uppercase the result, and cap it to 8 characters. A non-string input returns an empty string. ```javascript -import { parseLicensePlate } from '@brazilian-utils/brazilian-utils'; +import { parsePassport } from '@brazilian-utils/brazilian-utils'; -parseLicensePlate('abc-1234'); // 'ABC1234' +parsePassport('AB-123.456'); // 'AB123456' +parsePassport(' AB 123 456 '); // 'AB123456' ``` -## convertLicensePlateToMercosul +### generatePassport -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`). Returns `""` when the value is not a valid old format license plate. +Generate a random valid Brazilian passport number. Uses `Math.random()` internally, so it is not cryptographically secure. ```javascript -import { convertLicensePlateToMercosul } from '@brazilian-utils/brazilian-utils'; +import { generatePassport } from '@brazilian-utils/brazilian-utils'; -convertLicensePlateToMercosul('ABC1234'); // 'ABC1C34' -convertLicensePlateToMercosul('abc-1234'); // 'ABC1C34' -convertLicensePlateToMercosul('ABC1D23'); // '' (already Mercosul) +generatePassport(); // 'RY393097' ``` -## generatePis +## CNH -Generate a valid random PIS. +### isValidCnh -```javascript -import { generatePis } from '@brazilian-utils/brazilian-utils'; - -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`. +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 { getMunicipality } from '@brazilian-utils/brazilian-utils'; - -await getMunicipality({ code: '3550308' }); -// ['São Paulo', 'SP'] - -await getMunicipality({ code: 3550308 }); -// ['São Paulo', 'SP'] - -await getMunicipality({ municipalityName: 'sao paulo', uf: 'sp' }); -// '3550308' - -await getMunicipality({ code: '0000000' }); -// null (unknown code) +import { isValidCnh } from '@brazilian-utils/brazilian-utils'; -await getMunicipality({ code: '123' }); -// null (not 7 digits) +isValidCnh('00000000119'); // true +isValidCnh('000000001-19'); // true (hyphen before the check digits) +isValidCnh('ab00000000119'); // false (letters are rejected) ``` -## 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. +### formatCnh -```javascript -import { getMunicipalities } from '@brazilian-utils/brazilian-utils'; - -// Return every Brazilian municipality (sorted by name). -getMunicipalities(); -// [ -// { code: '5200050', name: 'Abadia de Goiás', stateCode: 'GO' }, -// { code: '3100104', name: 'Abadia dos Dourados', stateCode: 'MG' }, -// { code: '5200100', name: 'Abadiânia', stateCode: 'GO' }, -// { code: '3100203', name: 'Abaeté', stateCode: 'MG' }, -// { code: '1500107', name: 'Abaetetuba', stateCode: 'PA' }, -// ... 5566 more items -// ] - -// Return every municipality of the São Paulo state. -getMunicipalities('SP'); -// [ -// { code: '3500105', name: 'Adamantina', stateCode: 'SP' }, -// { code: '3500204', name: 'Adolfo', stateCode: 'SP' }, -// { code: '3500303', name: 'Aguaí', stateCode: 'SP' }, -// { code: '3500402', name: 'Águas da Prata', stateCode: 'SP' }, -// { code: '3500501', name: 'Águas de Lindóia', stateCode: 'SP' }, -// ... 640 more items -// ] +Format CNH. `options.pad` (part of `FormatCnhOptions`) left-pads the value with zeros to the full 11 digits before masking (default `false`). -getMunicipalities('ZZ'); // [] +```javascript +import { formatCnh } from '@brazilian-utils/brazilian-utils'; + +formatCnh('02650306461'); // 026503064-61 +formatCnh('2650306461', { pad: true }); // 026503064-61 ``` -`getMunicipalities` embeds all 5571 IBGE municipalities and their codes, so it carries the same bundle-size cost as `getCities`. See [Bundle size](getting-started.md#bundle-size) for how to lazy-load it via `@brazilian-utils/brazilian-utils/get-municipalities` instead of the root import. +### parseCnh -## getMunicipalityByCode +Remove CNH formatting, keep only digits, and cap the result to 11 digits. Returns `''` when there is no digit at all. -Look up a Brazilian municipality by its 7-digit IBGE code. Accepts the code as a string or a number, with any non-digit characters stripped before matching; a code given as a number must be a non-negative integer, so `-3550308` and `355030.8` return `null` instead of being read as `3550308`. Returns `{ code, name, stateCode }`, a fresh object, or `null` when the code is not 7 digits long or does not match any known municipality. +```javascript +import { parseCnh } from '@brazilian-utils/brazilian-utils'; + +parseCnh('026503064-61'); // '02650306461' +``` + +### generateCnh + +Generate a valid random CNH. Uses `Math.random()` internally, so it is not cryptographically secure. ```javascript -import { getMunicipalityByCode } from '@brazilian-utils/brazilian-utils'; +import { generateCnh } from '@brazilian-utils/brazilian-utils'; -getMunicipalityByCode('3550308'); -// { code: '3550308', name: 'São Paulo', stateCode: 'SP' } +generateCnh(); // '02650306461' +``` -getMunicipalityByCode(3550308); -// { code: '3550308', name: 'São Paulo', stateCode: 'SP' } +## Legal nature -getMunicipalityByCode('0000000'); // null (unknown code) -getMunicipalityByCode('123'); // null (not 7 digits) +### isValidLegalNature + +Check if a legal nature code exists in the official list. The table follows IBGE/CONCLA's "Natureza Jurídica 2021": the 92 codes in force plus the 8 a past revision of the table retired, kept because they still appear in records filed while they were in force. Use `getLegalNature` to tell the two apart: a retired code comes back with `legacy: true` and the `currentCode` it corresponds to today. Only the usual mask characters (hyphens, dots, whitespace) are tolerated around the 4 digits, so `'2062a'` is rejected instead of being read as `'2062'`. + +```javascript +import { isValidLegalNature } from '@brazilian-utils/brazilian-utils'; + +isValidLegalNature('2062'); // true +isValidLegalNature('2208'); // true (retired by a past revision, still accepted) +isValidLegalNature('9999'); // false ``` -## isHoliday +### formatLegalNature -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`. +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 { isHoliday } from '@brazilian-utils/brazilian-utils'; +import { formatLegalNature } from '@brazilian-utils/brazilian-utils'; -isHoliday({ targetDate: new Date(2024, 0, 1) }); // true -isHoliday({ targetDate: new Date(2024, 6, 9), stateCode: 'SP' }); // true -isHoliday(); // false +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) ``` -## isBusinessDay +### parseLegalNature -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`. +Remove legal nature formatting, keep only digits, and cap the result to 4 digits. ```javascript -import { isBusinessDay } from '@brazilian-utils/brazilian-utils'; +import { parseLegalNature } from '@brazilian-utils/brazilian-utils'; -isBusinessDay(new Date(2024, 0, 2)); // true (Tuesday, not a holiday) -isBusinessDay(new Date(2024, 0, 1)); // false (Ano novo) -isBusinessDay(new Date(2024, 0, 6)); // false (Saturday) -isBusinessDay(new Date(2024, 1, 13)); // false (Carnaval, optional holiday, counts by default) -isBusinessDay(new Date(2024, 1, 13), { includeOptional: false }); // true -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 +parseLegalNature('206-2'); // '2062' ``` -## addBusinessDays +### generateLegalNature -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`. +Generate a random valid legal nature code. Only the 92 codes in force are drawn, never one of the 8 a past revision retired. Uses `Math.random()` internally, so it is not cryptographically secure. ```javascript -import { addBusinessDays } from '@brazilian-utils/brazilian-utils'; +import { generateLegalNature } 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) +generateLegalNature(); // '2062' ``` -## differenceInBusinessDays +### getLegalNature + +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. -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`. +A code a past revision of the table retired is still looked up, because it keeps appearing in records filed while it was in force, and comes back with `legacy: true` and the `currentCode` it corresponds to today, per the CONCLA correspondence spreadsheets. The 92 codes in force have `legacy: false` and no `currentCode`. + +| Retired code | Description | Corresponds to | +| --- | --- | --- | +| `2076` | Sociedade Empresária em Nome Coletivo | `2070`, the code the 2003.1 revision renumbered it to, same denomination | +| `2100` | Sociedade Mercantil de Capital e Indústria | none, marked "categoria extinta" by the 2003.1 x 2009 correspondence | +| `2208` | Entidade Binacional Itaipu | `2275` Empresa Binacional | +| `3042` | Organização Social | `3069` Fundação Privada; the 2014 revision later created `3301` Organização Social (OS), where an entity qualified as one is classified today | +| `3050` | Organização da Sociedade Civil de Interesse Público (Oscip) | none, an Oscip is classified by the form it takes (`3999` or `3069`) | +| `3093` | Unidade Executora (Programa Dinheiro Direto na Escola) | `3999` Associação Privada | +| `3123` | Partido Político | none, the 2014 revision split it into `3255`, `3263` and `3271` | +| `5002` | Organização Internacional e Outras Instituições Extraterritoriais | `5010` Organização Internacional, the code it was opened into alongside `5029` and `5037` | ```javascript -import { differenceInBusinessDays } from '@brazilian-utils/brazilian-utils'; +import { getLegalNature } 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 +getLegalNature('2062'); +// { +// code: '2062', +// description: 'Sociedade Empresária Limitada', +// category: { code: '2', description: 'Entidades Empresariais' }, +// legacy: false, +// } +getLegalNature('2208'); +// { +// code: '2208', +// description: 'Entidade Binacional Itaipu', +// category: { code: '2', description: 'Entidades Empresariais' }, +// legacy: true, +// currentCode: '2275', +// } +getLegalNature('3123')?.currentCode; // null (retired without a successor) +getLegalNature('206-2')?.code; // '2062' +getLegalNature(206.2)?.category.description; // 'Entidades Empresariais' +getLegalNature('0000'); // null ``` -## convertDateToWords +### getLegalNatures -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. +Get the legal nature map keyed by code. Only the 92 codes of the CONCLA 2021 table, the ones in force, are listed by default; pass `{ includeLegacy: true }` (`GetLegalNaturesParams`) to add the 8 a past revision of the table retired. ```javascript -import { convertDateToWords } from '@brazilian-utils/brazilian-utils'; +import { getLegalNatures } 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" -convertDateToWords('10/05/1999'); // "dez de maio de mil novecentos e noventa e nove" -convertDateToWords('31/04/2024'); // "" (April has 30 days) -convertDateToWords('invalid'); // "" -convertDateToWords('29/02/1900'); // "" (1900 is not a leap year) +const legalNatures = getLegalNatures(); + +legalNatures['2062']; // 'Sociedade Empresária Limitada' +Object.keys(legalNatures).length; // 92 +legalNatures['2208']; // undefined (retired by a past revision) +getLegalNatures({ includeLegacy: true })['2208']; // 'Entidade Binacional Itaipu' ``` -## formatVoterId +### getLegalNaturesByCategory -Format a voter ID number. Uses the 12-digit grouping `0000 0000 00 00` by default; the 13-digit grouping `0000 0000 0 00 00` is used only when the sanitized value has more than 12 digits **and** its federative union code (the 10th and 11th digits) is `01` (São Paulo) or `02` (Minas Gerais), the two states whose voter ids may carry a 9-digit sequential number. +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 `[]`. Only the codes in force are listed by default; pass `{ includeLegacy: true }` (`GetLegalNaturesByCategoryOptions`) to add the retired codes of the category, in code order. ```javascript -import { formatVoterId } from '@brazilian-utils/brazilian-utils'; +import { getLegalNaturesByCategory } from '@brazilian-utils/brazilian-utils'; -formatVoterId('123456780175'); // '1234 5678 01 75' -formatVoterId('1234567880191'); // '1234 5678 8 01 91' (13-digit SP/MG voter id) +getLegalNaturesByCategory('4')[0]; +// { +// code: '4014', +// description: 'Empresa Individual Imobiliária', +// category: { code: '4', description: 'Pessoas Físicas' }, +// legacy: false, +// } +getLegalNaturesByCategory(4).length; // 6 +getLegalNaturesByCategory('2').length; // 30 +getLegalNaturesByCategory('2', { includeLegacy: true }).length; // 33 +getLegalNaturesByCategory('9'); // [] ``` -## isValidVoterId +## Voter ID -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`). +### 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`). 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'; @@ -1489,19 +1651,18 @@ const voterId = generateVoterId('SP'); isValidVoterId(voterId); // true ``` -## generateVoterId +### formatVoterId -Generate a valid random voter ID number. You can optionally provide a state code; an unknown state code falls back to `"ZZ"` (issued abroad) instead of throwing. Uses `Math.random()` internally, so it is not cryptographically secure. +Format a voter ID number. Uses the 12-digit grouping `0000 0000 00 00` by default; the 13-digit grouping `0000 0000 0 00 00` is used only when the sanitized value has more than 12 digits **and** its federative union code (the 10th and 11th digits) is `01` (São Paulo) or `02` (Minas Gerais), the two states whose voter ids may carry a 9-digit sequential number. ```javascript -import { generateVoterId } from '@brazilian-utils/brazilian-utils'; +import { formatVoterId } from '@brazilian-utils/brazilian-utils'; -generateVoterId(); // valid random voter ID (abroad, "ZZ") -generateVoterId('SP'); // valid random voter ID for Sao Paulo -generateVoterId('XX'); // falls back to "ZZ" instead of throwing +formatVoterId('123456780175'); // '1234 5678 01 75' +formatVoterId('1234567880191'); // '1234 5678 8 01 91' (13-digit SP/MG voter id) ``` -## parseVoterId +### 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). @@ -1512,36 +1673,65 @@ parseVoterId('1234 5678 01 75'); // '123456780175' parseVoterId('1234 5678 8 01 91'); // '1234567880191' (13-digit SP/MG voter id) ``` -## isValidCns +### generateVoterId + +Generate a valid random voter ID number. You can optionally provide a state code; an unknown state code falls back to `"ZZ"` (issued abroad) instead of throwing. Uses `Math.random()` internally, so it is not cryptographically secure. + +```javascript +import { generateVoterId } from '@brazilian-utils/brazilian-utils'; + +generateVoterId(); // valid random voter ID (abroad, "ZZ") +generateVoterId('SP'); // valid random voter ID for Sao Paulo +generateVoterId('XX'); // falls back to "ZZ" instead of throwing +``` + +## CNS + +### 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 +### 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' ``` -## isValidCertidao +### 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' +``` + +## Certidão + +### 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 +1745,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 ``` -## parseCertidao +### 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 +1790,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 +1808,11 @@ 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 -``` +## CEI, CNO and CAEPF -## isValidCei +### 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'; @@ -1620,9 +1824,9 @@ isValidCei('24.985.96743/68'); // false (invalid check digit) isValidCei('000000000000'); // false (repeated digits) ``` -## formatCei +### 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 ``` -## isValidCno +### 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'; @@ -1646,9 +1860,9 @@ isValidCno('110840168063'); // false (invalid check digit) isValidCno('000000000000'); // false (repeated digits) ``` -## formatCno +### 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 ``` -## isValidCaepf +### 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 +### 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,35 +1909,21 @@ formatCaepf(41142260000101); // 411.422.600/001-01 formatCaepf('184', { pad: true }); // 000.000.000/001-84 ``` -## isValidRegistroProfissional +### parseCaepf -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). +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 { isValidRegistroProfissional } from '@brazilian-utils/brazilian-utils'; +import { parseCaepf } 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 +parseCaepf('293.118.610/001-84'); // '29311861000184' ``` -## 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. - -```javascript -import { isValidVin } from '@brazilian-utils/brazilian-utils'; - -isValidVin('1HGCM82633A004352'); // true -isValidVin('1m8gdm9axkp042788'); // true (check digit X, lowercase) -isValidVin('1HGCM82633A004353'); // false (bad check digit) -isValidVin('1HGCM8263IA004352'); // false (contains the excluded letter I) -``` +## Classification codes (CBO, CNAE, NCM, CFOP, CST, CSOSN) -## isValidCbo +### 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,145 +1931,273 @@ 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 +### 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 +### 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) ``` -## formatCnae +### 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 +### 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 +### 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 +### 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 ``` -## isValidCfop +### parseNcm -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. +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. 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 +### 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 +### isValidCst Check if a CST (Código de Situação Tributária) code is valid for a given tax. Pass the tax through `options.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 +### 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) +``` + +## Text + +### 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. 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('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 (case-insensitive match) +capitalize(' josé maria '); // José Maria (every run of whitespace, tabs and newlines included, collapses into one space) ``` -## removeAccents +### 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. @@ -1871,3 +2210,71 @@ removeAccents('Ceará'); // 'Ceara' removeAccents('Açaí'); // 'Acai' removeAccents(''); // '' ``` + +## 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`). 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. The registration and the state code go together in a single object, typed as `IsValidIeParams`; the 2.3.0 form, `isValidIe(stateCode, ie)`, still works and is deprecated. + +```javascript +import { isValidIe } from '@brazilian-utils/brazilian-utils'; + +isValidIe({ value: '0187634580933', stateCode: 'AC' }); // false +isValidIe({ value: '109161793', stateCode: 'go' }); // true (case-insensitive) +``` + +## isValidEmail + +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'; + +isValidEmail('john.doe@hotmail.com'); // true +``` + +## 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 (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'; + +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) +``` + +## isValidRegistroProfissional + +Check the structure of a professional council registration number (registro/inscrição profissional). It takes a single object, typed as `IsValidRegistroProfissionalParams`, 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({ 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º 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'; + +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) +``` diff --git a/package-lock.json b/package-lock.json index c26257972..cda9f9ee2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,16 +17,16 @@ "@stryker-mutator/vitest-runner": "10.0.0", "@vitest/browser-webdriverio": "4.1.11", "@vitest/coverage-v8": "4.1.11", - "esbuild": "0.27.2", - "eslint-plugin-sonarjs": "^4.2.0", - "fast-check": "4.10.0", - "jscpd": "5.2.0", - "knip": "6.35.1", + "esbuild": "0.28.2", + "eslint-plugin-sonarjs": "^4.2.1", + "fast-check": "4.10.1", + "jscpd": "5.2.1", + "knip": "6.36.0", "lockfile-lint": "5.0.1", "publint": "0.3.24", - "typescript": "5.9.3", - "vite-plus": "0.3.0", - "webdriverio": "9.31.6" + "typescript": "7.0.2", + "vite-plus": "0.3.2", + "webdriverio": "9.31.9" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -1681,9 +1681,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", - "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", "cpu": [ "ppc64" ], @@ -1698,9 +1698,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", - "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", "cpu": [ "arm" ], @@ -1715,9 +1715,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", - "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", "cpu": [ "arm64" ], @@ -1732,9 +1732,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", - "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", "cpu": [ "x64" ], @@ -1749,9 +1749,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", - "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", "cpu": [ "arm64" ], @@ -1766,9 +1766,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", - "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", "cpu": [ "x64" ], @@ -1783,9 +1783,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", - "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", "cpu": [ "arm64" ], @@ -1800,9 +1800,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", - "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", "cpu": [ "x64" ], @@ -1817,9 +1817,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", - "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", "cpu": [ "arm" ], @@ -1834,9 +1834,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", - "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", "cpu": [ "arm64" ], @@ -1851,9 +1851,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", - "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", "cpu": [ "ia32" ], @@ -1868,9 +1868,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", - "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", "cpu": [ "loong64" ], @@ -1885,9 +1885,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", - "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", "cpu": [ "mips64el" ], @@ -1902,9 +1902,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", - "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", "cpu": [ "ppc64" ], @@ -1919,9 +1919,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", - "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", "cpu": [ "riscv64" ], @@ -1936,9 +1936,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", - "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", "cpu": [ "s390x" ], @@ -1953,9 +1953,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", - "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", "cpu": [ "x64" ], @@ -1970,9 +1970,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", - "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", "cpu": [ "arm64" ], @@ -1987,9 +1987,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", - "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", "cpu": [ "x64" ], @@ -2004,9 +2004,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", - "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", "cpu": [ "arm64" ], @@ -2021,9 +2021,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", - "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", "cpu": [ "x64" ], @@ -2038,9 +2038,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", - "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", "cpu": [ "arm64" ], @@ -2055,9 +2055,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", - "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", "cpu": [ "x64" ], @@ -2072,9 +2072,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", - "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", "cpu": [ "arm64" ], @@ -2089,9 +2089,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", - "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", "cpu": [ "ia32" ], @@ -2106,9 +2106,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", - "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", "cpu": [ "x64" ], @@ -2898,6 +2898,20 @@ "node": ">=10" } }, + "node_modules/@microsoft/api-extractor/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/@microsoft/tsdoc": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz", @@ -2984,19 +2998,6 @@ "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, - "node_modules/@nodable/entities": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", - "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/nodable" - } - ], - "license": "MIT" - }, "node_modules/@oxc-parser/binding-android-arm-eabi": { "version": "0.148.0", "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.148.0.tgz", @@ -3345,9 +3346,9 @@ } }, "node_modules/@oxc-project/runtime": { - "version": "0.146.0", - "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.146.0.tgz", - "integrity": "sha512-lbXHIpZ1MmK6zuw5txlMdIZ2waLVUIU5Gnm3sEuwJOiqDfQfbtjeHscatmeBoxbv8+If9LFM6PGh/3DcDWYIYw==", + "version": "0.149.0", + "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.149.0.tgz", + "integrity": "sha512-XNtswoJPeaVa+Ry8A1FdEaJTAu6xZ8zg4ZijGPOAWWMRCUuDekEDwxmtIgP5DG67Sh1CVmq+bnTbqhnGHhn/Lg==", "dev": true, "license": "MIT", "engines": { @@ -3355,13 +3356,13 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.146.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz", - "integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==", + "version": "0.149.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", + "integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==", "dev": true, "license": "MIT", "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" } }, "node_modules/@oxc-resolver/binding-android-arm-eabi": { @@ -3660,9 +3661,9 @@ ] }, "node_modules/@oxfmt/binding-android-arm-eabi": { - "version": "0.64.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.64.0.tgz", - "integrity": "sha512-o6uzh/jTOQeAY5TdkAeXdqv7MBRcPxiRA08zrcBtkKj5cSu/FMu0Hl7Q6Fi1KCKyCWZ6lJVjBzdsJvsKltUsGQ==", + "version": "0.67.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.67.0.tgz", + "integrity": "sha512-2olh3ioEmc4gRzQm7jxyB1b/PFBoFvTq8KdgYySeNpysDtA6DEg2Mvya4/I6flhL7G0eOrE8RD7JCNCIMhE16Q==", "cpu": [ "arm" ], @@ -3677,9 +3678,9 @@ } }, "node_modules/@oxfmt/binding-android-arm64": { - "version": "0.64.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.64.0.tgz", - "integrity": "sha512-jRGSUeeP7p3Gynw2YaCVtjBIA6ZxY6bEB/ES5i54OhqmRTyuVg7ZgstEtzgq6GOAJd+2QZ5pvf+bFfmW5Mp9cw==", + "version": "0.67.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.67.0.tgz", + "integrity": "sha512-ulfw8EHN1MBq/MFFDXw2/M1VAFu5mRUcnuZ8Hqbv9viAnFzO9t1jKSAsDqKYYDGMlytF/uj6Z5z5n/tHupnKhw==", "cpu": [ "arm64" ], @@ -3694,9 +3695,9 @@ } }, "node_modules/@oxfmt/binding-darwin-arm64": { - "version": "0.64.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.64.0.tgz", - "integrity": "sha512-JINwtU2lW7nOFSqi+H2qplipNUqah9Gc1jgGmB82kTD4UnZrZIVxCJ9qEmFiKfjNq27gYLFhrUb0to86aCwMjw==", + "version": "0.67.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.67.0.tgz", + "integrity": "sha512-MfONZx/O2o9M5v2jDFol556G9+A+P9xCuJ4DZ+qhE+RnaCdoscy6Eu5nq1dbuNxhwdJyZ6kLI7fnG9mwEeOeGg==", "cpu": [ "arm64" ], @@ -3711,9 +3712,9 @@ } }, "node_modules/@oxfmt/binding-darwin-x64": { - "version": "0.64.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.64.0.tgz", - "integrity": "sha512-gCmuswrgrOSajV4HCRFkVCGIruPq8bjYuPYgSE2WQB3mD6XrdyZ3JMSRZCkQ8zCxOyGWriBo6QoZ5nmMHQ1BfA==", + "version": "0.67.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.67.0.tgz", + "integrity": "sha512-CYnIx5LvFVJnyJcCqwH2jxMKjFjqo5678MPjdmNFoSGMhlOvZ/xRZqvhDcolKrXc8fezW3AKh+C4wyoFuWOSSg==", "cpu": [ "x64" ], @@ -3728,9 +3729,9 @@ } }, "node_modules/@oxfmt/binding-freebsd-x64": { - "version": "0.64.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.64.0.tgz", - "integrity": "sha512-Ab8g7a38pT0MMImjh7anRSTve6buWBIlcXIFBYa5xl4s6UxEgKSc2xOOhbGtLwvXnEi2PsEDGoJh3oUU7xkehQ==", + "version": "0.67.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.67.0.tgz", + "integrity": "sha512-7/iF1orvIS9mxhKUqnmtMgm+OrSQ5acPwuvdQrm6ECgqbwPmC+Pw9cdke3sNfVN6pT2hbJ58+jP8BCThl5HXOg==", "cpu": [ "x64" ], @@ -3745,9 +3746,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { - "version": "0.64.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.64.0.tgz", - "integrity": "sha512-BgvS3CoQ+Xy2deoZqEN8JVKabcCZi2RxA3yant8G9OAv9KuPJ9TCjHkqigzdHUVwErZxEP5d2bzLIEyKYyBDLg==", + "version": "0.67.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.67.0.tgz", + "integrity": "sha512-yy+OGys07IZOpOmYPZoObKyUQLkfxeQqeCypk+1jaZd8HGo77hzvU1Jg8X3+W75o+9lszOjBfg0nkGtlwYywXw==", "cpu": [ "arm" ], @@ -3762,9 +3763,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm-musleabihf": { - "version": "0.64.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.64.0.tgz", - "integrity": "sha512-QXpNxwoMj0YvnceCNZadNSden3bIcnvjn/sDp/rwZhRoZoZYGpHvtPyhGsdJz9uvT9GkaMW7SsLddurU56dt8w==", + "version": "0.67.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.67.0.tgz", + "integrity": "sha512-wPIeeigXgJpwNw3wydYRt3U9iN9Y/ejpOZuYL9IA7igxWs7LIQMOkhKxTumRvy6dIv0iXKk3RTw3Vmjg0i+2sg==", "cpu": [ "arm" ], @@ -3779,9 +3780,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm64-gnu": { - "version": "0.64.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.64.0.tgz", - "integrity": "sha512-BBgH3I1ppDsI5pZ4Pdhw0ceYxwVCfbU/bZEBCeZ6caRS9x0ZabErxubP7riGUn11PXZBhe8DYdjkDKP1FlVQ5w==", + "version": "0.67.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.67.0.tgz", + "integrity": "sha512-0+XNxcdbkTfxdcD4qW6Ci9n+mBNJ8xTBumnxKvKBmRFOdx0Wf8/KiHjCJayooXmYkqRpRVd98Q5egvzx5BLSgQ==", "cpu": [ "arm64" ], @@ -3799,9 +3800,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm64-musl": { - "version": "0.64.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.64.0.tgz", - "integrity": "sha512-v19HSjC/BGXdt26qEvKZtwAHgGmQ2Agcap2kQP+KIqoRZqivVzYth3ui2dJA1i+6/fjpjga85lIOaJJjQ/bOOw==", + "version": "0.67.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.67.0.tgz", + "integrity": "sha512-I75LKPJyNOYUzkqAiAMIE31+Ye7xtQXZdoty1IXn4B+bw5Zpmez5wfG19ejGpNnS/BzQ7LFS+7jxuTPb+vHiZw==", "cpu": [ "arm64" ], @@ -3819,9 +3820,9 @@ } }, "node_modules/@oxfmt/binding-linux-ppc64-gnu": { - "version": "0.64.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.64.0.tgz", - "integrity": "sha512-PElLnOo4xFTBZrxPhgTIj0eHqZXwEBQoNWtb7facUV170T0B0FRET0iNbb3LUeLWTybkUW+vsdyv4ihOdyXGyw==", + "version": "0.67.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.67.0.tgz", + "integrity": "sha512-c2M5iRpe1QMZSRE/UvZoPdXBWb5Ic/ycvOyNiKCqPwQ/OyOKIMiJs02ynlNnjb7ZZJnRXYLmGcohoINOcwDK3w==", "cpu": [ "ppc64" ], @@ -3839,9 +3840,9 @@ } }, "node_modules/@oxfmt/binding-linux-riscv64-gnu": { - "version": "0.64.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.64.0.tgz", - "integrity": "sha512-Qzsg15n4F5CH+MorcRW4MkAEMiLzXmeG+DiDSbP/bBTqCmWOH3K9DHryNrve+JHlV0txS+B6Z9P5Xz+cmWeL+g==", + "version": "0.67.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.67.0.tgz", + "integrity": "sha512-dQzzYlV24Udhfm5ECuSdgqRvFJU/CGHzcYYEO3dLM6W6+CHiBFrq9OjIllkdCcPhsoSQ8o223Dja84MOSzed9A==", "cpu": [ "riscv64" ], @@ -3859,9 +3860,9 @@ } }, "node_modules/@oxfmt/binding-linux-riscv64-musl": { - "version": "0.64.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.64.0.tgz", - "integrity": "sha512-/GZ358wnQ/Ez4UVnCcZIi56JkY0sOdZ+B108pqXKqZz3jLS59F4KEAB1Qv3fRlObrFEk+3L2vUQ/xoPx+3vjXw==", + "version": "0.67.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.67.0.tgz", + "integrity": "sha512-rFNq1CgX4qMJANOq42LkAs90JE80GpiaEohAV2qn/gT2hGjQTW1zBO5zQBxArI4926pM1OSzo3CN0tBszGBIaA==", "cpu": [ "riscv64" ], @@ -3879,9 +3880,9 @@ } }, "node_modules/@oxfmt/binding-linux-s390x-gnu": { - "version": "0.64.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.64.0.tgz", - "integrity": "sha512-/C9We3DXegowfLXtVCYHeNiU9azwCDr5cQkEtCVlc74vyn+lLQSPApJ1CZmxAduqeq/Oi3gQ+IVptyhCaTMtkQ==", + "version": "0.67.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.67.0.tgz", + "integrity": "sha512-Sky6rEdz2o5IGq01lPhS12yEvDdChVEcaYrcLHkveh4Fx0qPjljE/Iul6SX/bRMl6lNc8J7J/mDQdzgBdA++Pg==", "cpu": [ "s390x" ], @@ -3899,9 +3900,9 @@ } }, "node_modules/@oxfmt/binding-linux-x64-gnu": { - "version": "0.64.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.64.0.tgz", - "integrity": "sha512-91KM2CeRWscIEHlj1NsW2WSnzGeq1Ehq+39bfDowTdkn+fcvK/x4Y1RcyqT7glyBjZio0ldkeCG6Usj3v7ASog==", + "version": "0.67.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.67.0.tgz", + "integrity": "sha512-vPXmlNORV8AZq2Ocxh07pxwMjfENUWCV/eZArnao0qC3NO/hDeTVkQvee7SJJUbIiF5PZbBa4kYmaXnu7Rk58w==", "cpu": [ "x64" ], @@ -3919,9 +3920,9 @@ } }, "node_modules/@oxfmt/binding-linux-x64-musl": { - "version": "0.64.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.64.0.tgz", - "integrity": "sha512-gw7uEk9I+7zoT1EYLra1eWArIzNcz8e3jkv+Noo2+o2T7wPvsNSQbfoa4DSfZlvn1i6mJ05RiZ4/omaXPDNhQg==", + "version": "0.67.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.67.0.tgz", + "integrity": "sha512-x/WAtFqYtVr3vZ9ni8nr4kn9whSitg8fOljq/pZzBpxopRdY1BMLZCZkrbIbaBcYkm46qGbqVea2FCWmtQ2P9w==", "cpu": [ "x64" ], @@ -3939,9 +3940,9 @@ } }, "node_modules/@oxfmt/binding-openharmony-arm64": { - "version": "0.64.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.64.0.tgz", - "integrity": "sha512-HYHFf616FHSPSO07c09mjmXBfQ73wIVM3m0txOiooa5XZkGoxFd6B14PVj0LB0DXIqJ6wAO/dDR/NX/5UUaqnw==", + "version": "0.67.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.67.0.tgz", + "integrity": "sha512-eRw9Neh4/aA6i+q/R3WU1gGQINhVM0J4fXIm6t27caOamkr/37uAkp1IdBx4zlJH97hmXR63z/q9n5c5dN7MzA==", "cpu": [ "arm64" ], @@ -3956,9 +3957,9 @@ } }, "node_modules/@oxfmt/binding-win32-arm64-msvc": { - "version": "0.64.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.64.0.tgz", - "integrity": "sha512-uQjFp081IZSWD6VAofX2iO2z01awAdHmfC+NrieWIPKrT2hZKQDyq/U18M7ifC0sm0Wz8aHY/p6+FDYIzs/CrQ==", + "version": "0.67.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.67.0.tgz", + "integrity": "sha512-YIMvb+sGNYN2uc6+QK2HLPeEKM2vl7QZ5onQzpAJRb6pnf0DwUFP5R8tdS9R0l8hdUil2gu4Uxd0Yxrop0iT4w==", "cpu": [ "arm64" ], @@ -3973,9 +3974,9 @@ } }, "node_modules/@oxfmt/binding-win32-ia32-msvc": { - "version": "0.64.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.64.0.tgz", - "integrity": "sha512-lNM6byTAQ881jugzFu8juJTbNRgsUTlswMA6pJmwi1XDvmIqnnb49lcUAs5gz94fCJLrVN+/X3s3jOKqx23WIQ==", + "version": "0.67.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.67.0.tgz", + "integrity": "sha512-LzmU9MyACPzwNDIK0ItMedHPz735Ug7ELWguxo4/kuy6zWuDoeglOAEFCY8jLg0PzRpFO3hDyLFe2Gu2eFDeGA==", "cpu": [ "ia32" ], @@ -3990,9 +3991,9 @@ } }, "node_modules/@oxfmt/binding-win32-x64-msvc": { - "version": "0.64.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.64.0.tgz", - "integrity": "sha512-BtmbtL/QjMtF1a6C3CqoDluH2IfB6fJt62E+B9RFfUPtFk4Iz9PFS6+y/SzzOvSxc7aUk2Kphwg7Dh8lMbwu6g==", + "version": "0.67.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.67.0.tgz", + "integrity": "sha512-sbQOIDNLUEeVZcAJcSL5VURn7kfjvilPviody4Yl5n8lQCDtUm+C9oHTTwZS/m4d/Z6Vv3jNEiAofH932NPPCg==", "cpu": [ "x64" ], @@ -4091,9 +4092,9 @@ ] }, "node_modules/@oxlint/binding-android-arm-eabi": { - "version": "1.79.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.79.0.tgz", - "integrity": "sha512-TebFaaMklO/RXzTv7PucaCq9l3X6D1gA+C8H6K4njtjFOV+zWE9MKLpulcJZN9bzytbUbQIY0mZuz12nQ5Kv4Q==", + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.82.0.tgz", + "integrity": "sha512-a3LB+C5Dsj5b/qtmG/mv5WrzuiXEpg1KF5nXWcEvaoN5TYAqkIvxPOwTPp3Jy/FoGpRo8zsTFhMElMXfeoOEzA==", "cpu": [ "arm" ], @@ -4108,9 +4109,9 @@ } }, "node_modules/@oxlint/binding-android-arm64": { - "version": "1.79.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.79.0.tgz", - "integrity": "sha512-KqqnOtAVgNsPPF0YSodkFZA1O80jcKoCZCTu3bgsszxA+MrMP9TLzfXitKjEj1FmrPprKDMdRDMmY3weESO9sg==", + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.82.0.tgz", + "integrity": "sha512-OBlhRgNqFblGpGenno/aqOfJLOkQ2B8Ig3iDAalfn0H8hJGZKXPeexCRTDm6uwv6YUjSA9Xnwt1y/Bgj5ZH8uw==", "cpu": [ "arm64" ], @@ -4125,9 +4126,9 @@ } }, "node_modules/@oxlint/binding-darwin-arm64": { - "version": "1.79.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.79.0.tgz", - "integrity": "sha512-BVC2nsMzqQzRDPc5RhixkZ+m1p7iH4bxRRvqkbwDXX0PlQKm1BPy8J8cRjnAFafOq2QzI+BfO3vE8w2GZ3CBag==", + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.82.0.tgz", + "integrity": "sha512-dsopxqtY5ZdyT9uLHyGt1SyiLop6hi7hWI3PKpePodkRQOkLaCm+OE4fR9CAz9qdfjiFO8531tX/QDyP/psjFg==", "cpu": [ "arm64" ], @@ -4142,9 +4143,9 @@ } }, "node_modules/@oxlint/binding-darwin-x64": { - "version": "1.79.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.79.0.tgz", - "integrity": "sha512-p6Lm+snmhGuLKL1+CpCV8L6ijkE/qJzK2H2jG9+eKJT0n31RbY4FLsdhexekgP3bLpw4Kgde+9DZuDZQ4yIInA==", + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.82.0.tgz", + "integrity": "sha512-94Lu0SgTClKColU66g1VDuigV3HkcbkJBnTtZjGYfE8UPugaWDgKrm2icjC6HJVUYler2OXaHP/X0TBy8+CowQ==", "cpu": [ "x64" ], @@ -4159,9 +4160,9 @@ } }, "node_modules/@oxlint/binding-freebsd-x64": { - "version": "1.79.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.79.0.tgz", - "integrity": "sha512-qDMm0dXZnoHyRqSL4N4xUq82T4sqK5cbKSjvd/dF/YbMUXc2R1wEPf+vmA5S0qUmi0nwXfNbjXBtZaIqzQLIMg==", + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.82.0.tgz", + "integrity": "sha512-hne/V06ewhh1i0w8+l7GDNROAGCGPmyFuOwiP7YTRu0JycyStJ4785dmF8xU5p0uUwt2emvIF9vc7Xjis+cJ0g==", "cpu": [ "x64" ], @@ -4176,9 +4177,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-gnueabihf": { - "version": "1.79.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.79.0.tgz", - "integrity": "sha512-2od7s0nuKPzqyUZAWk9KkCyGg7eI9dwFPZg+20lB15fKFkVZ0c9ZFxqPfiBAyDTlTkh9stPI0t+JlPCqMbItVA==", + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.82.0.tgz", + "integrity": "sha512-aWY2xtbZf1LneW9Qsv/n2Sp8gOu74JrlQzEtj4coHX2SHFrCfhmAumaU+sI/A5nr+yoTRTSmI/pL2s6ADlNSkw==", "cpu": [ "arm" ], @@ -4193,9 +4194,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-musleabihf": { - "version": "1.79.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.79.0.tgz", - "integrity": "sha512-ZOQUjkzDnvlhSE3+tWC3YXx94MMl+sYMlwH+u1+YGApGHOJP/YAc8ZBRFOXZ6eOBmxtXAWuS/fBcdZr8qqNO1A==", + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.82.0.tgz", + "integrity": "sha512-Fe+TtXCXMh/5f7kWlZ2VAwsMumZWtraFlKVk1NJlL52/beGwfDE7ov+/8gVirHzWokzGu7X65hSPq0ucPDskWQ==", "cpu": [ "arm" ], @@ -4210,9 +4211,9 @@ } }, "node_modules/@oxlint/binding-linux-arm64-gnu": { - "version": "1.79.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.79.0.tgz", - "integrity": "sha512-lu158FR4nGqGeRS3BQvtG85wRgU/Fy4MD5Cxp1hzJXizGiLo6u2742wJSCDKh8cFcZntvX7fcxlq4mMmfryH1g==", + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.82.0.tgz", + "integrity": "sha512-6azCZ6OJudlvipNttXCCQcyeFfcJ/NvUZdSN1z8elo73kCHtyQC7WTiUcSjWYvJ1jaq9KDUyMAoAS/vNzhBomA==", "cpu": [ "arm64" ], @@ -4230,9 +4231,9 @@ } }, "node_modules/@oxlint/binding-linux-arm64-musl": { - "version": "1.79.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.79.0.tgz", - "integrity": "sha512-mbpKQeE2aflTjddaHK7MP8KP/OFbUM++lt5M635ENM8IyIdK0jm2t9pb+2v9mVVIvhF6TqA4l7F79Pll1mi+uw==", + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.82.0.tgz", + "integrity": "sha512-PLEaSD8IAIIlwW4dwOd9YaxuxeOpwiXL4J24rcnE4iNtyM5j9Q9/3+gti08oXpx0u2ygNjRDx9xjWWpQonuJEw==", "cpu": [ "arm64" ], @@ -4250,9 +4251,9 @@ } }, "node_modules/@oxlint/binding-linux-ppc64-gnu": { - "version": "1.79.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.79.0.tgz", - "integrity": "sha512-WpGNua7gaxaHnpSDeog2ji8IDHn/QLPl9LPzwkR/FvVv58vT5BcXjRXnU+wbu3N75cpeha8CdC7ho/U2OIsB4g==", + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.82.0.tgz", + "integrity": "sha512-D94em/BwknNTn4vqxjHh5wb2oL566eFhArabqKIr0cNZMHOJuiraFp1A8tXpH05bbE5tqwEfLXTI0MWEGtn3Dw==", "cpu": [ "ppc64" ], @@ -4270,9 +4271,9 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-gnu": { - "version": "1.79.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.79.0.tgz", - "integrity": "sha512-tK1E93A5LVzISg4ngpKJnfTs7EqtIUceGI7MQ4GyDjJiLi8wPCkEyKlj2xkyKWZ1yzkDJyLHTBJ5/iFWRdnJvg==", + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.82.0.tgz", + "integrity": "sha512-MOprxBaoYU2D4VgxXCl3ghydThWtx7Um1lL51kGYNeQ5Al7WzsH7/tqGdNtbLrIWnjq3bsm13+nz/gRIxjrOXw==", "cpu": [ "riscv64" ], @@ -4290,9 +4291,9 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-musl": { - "version": "1.79.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.79.0.tgz", - "integrity": "sha512-qhQvUIrngXivA2A9pQ+xPCychztn/5qUv7yS3gDwXv3w7Rag+eTeeXWmRyx+t7XsW5x6LuY/8AsTq36UgFIblg==", + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.82.0.tgz", + "integrity": "sha512-5h55QsfJ/luDXZzC20k6SNOY1Az+dCP9WvntKtcUWh2JhckAdwApY2ZusaBTwLENnReXU+A2fJtSrYvZJNKNPg==", "cpu": [ "riscv64" ], @@ -4310,9 +4311,9 @@ } }, "node_modules/@oxlint/binding-linux-s390x-gnu": { - "version": "1.79.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.79.0.tgz", - "integrity": "sha512-sv6AaVgU/eE6u+6WFiQVDcPPwTxP6IJMSB9k701W2r/r6Tx465e8vPvVyRxquNH4Vy6KwRNu90mVbxXJN8+5gg==", + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.82.0.tgz", + "integrity": "sha512-IE8NJNLlHr0CaXyGJPGVn0eTkUyoj1I2UfA8x7I4PSOYKsQ/6btVC7Pywrj5onk0cMH25r6Z38SoN3AvE5Zuog==", "cpu": [ "s390x" ], @@ -4330,9 +4331,9 @@ } }, "node_modules/@oxlint/binding-linux-x64-gnu": { - "version": "1.79.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.79.0.tgz", - "integrity": "sha512-iFZL02deziHslb3jEX9KdqlAkYoo4fGyotchKDzdfK1f5mxlIBeiQeHhvK3iFpuEJSB4ma/qeFn9oxPiwnhUPQ==", + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.82.0.tgz", + "integrity": "sha512-XUUUxaBo9XKl+J1B9EmP1cTGQPddzeURvoGkfwh/94PGnbW+hBprDljneoI2M1jzC1bzrIV3ihc7iM9UXl8+tg==", "cpu": [ "x64" ], @@ -4350,9 +4351,9 @@ } }, "node_modules/@oxlint/binding-linux-x64-musl": { - "version": "1.79.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.79.0.tgz", - "integrity": "sha512-3DtZR2raqObnh7wXZoFYFd0Fw7skBvcb3f7A+/lkEiDuh8hrE6vv9b/62Qxao1a9/OeHLw/FcXlXzgsW9wTRFg==", + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.82.0.tgz", + "integrity": "sha512-SWLSFulX9TDuH6yvbPYp4+VNn6jkkIvvI+KiujDM5rWBRHEfkesCC/pCneIIUr6ovkxZ5fRtpi2v5Cz5FrMJZg==", "cpu": [ "x64" ], @@ -4370,9 +4371,9 @@ } }, "node_modules/@oxlint/binding-openharmony-arm64": { - "version": "1.79.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.79.0.tgz", - "integrity": "sha512-Oatt4GuA1WJkqzk2ozx4HrWROOi7opV3AKDw/U8qDIqeTqzsjn5K2x3REJMNjU3/KU/Bkq96Zi3CknaiDTaC/Q==", + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.82.0.tgz", + "integrity": "sha512-BQy35f6ZUdNr9a6c7B7orxQTcLjByGT2z3WAgmRovpRwmPYAaJ+NTplmMzhdjdJ4qSchfMNZy/Ukg+qRg6zseQ==", "cpu": [ "arm64" ], @@ -4387,9 +4388,9 @@ } }, "node_modules/@oxlint/binding-win32-arm64-msvc": { - "version": "1.79.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.79.0.tgz", - "integrity": "sha512-NAgZr9Qp8nIA9rpo0JEvwiabTF/2UVqBNnupBG9X4kxXcQoScJUTi+qHhvabb9s/thgj5wQ4XcIaJvb+ZMgoKw==", + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.82.0.tgz", + "integrity": "sha512-V4QhSTg5gctZue8RJjsGi7NpQPThr/p1/HfmiMC5kfe1KFEup9SQRVub4A6kijQjdHfxj7bLL1KO3QO7/5bwMQ==", "cpu": [ "arm64" ], @@ -4404,9 +4405,9 @@ } }, "node_modules/@oxlint/binding-win32-ia32-msvc": { - "version": "1.79.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.79.0.tgz", - "integrity": "sha512-+KyXjIvcpaXmWW/j9NNY5yWjrIVxaX18VyIheQy3jwc2GSYgpCr7MGI/HxIGQ/shAL5IWEKbhsqoMpAO5Stiog==", + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.82.0.tgz", + "integrity": "sha512-TUSCLaKB2yktpFAJ/r3HAUYsaV/3DT7JS4iNKyoh3a9YNwD0UG7Ezh4D8m23654vQcU6P/RQrCAjRPKe4peP/A==", "cpu": [ "ia32" ], @@ -4421,9 +4422,9 @@ } }, "node_modules/@oxlint/binding-win32-x64-msvc": { - "version": "1.79.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.79.0.tgz", - "integrity": "sha512-mEelcCMMBS57sIXh2veGMNy+pQwuGtcMxHxGIZWQ5Ba9pJ5jCCUFOZB9E2JhBaxGsURe+WGe0zJp4RVre52gpQ==", + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.82.0.tgz", + "integrity": "sha512-VTVoRIWJTb+wvUX8EYoPArfFH02whuR10goFXE/LHRRX33ajRrFgqbcONXZMiF4C5rnattfkm87HqYn8jb8hmQ==", "cpu": [ "x64" ], @@ -5086,6 +5087,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@sonarsource/analyzer-commons-configurations": { + "version": "2.31.0-5284", + "resolved": "https://registry.npmjs.org/@sonarsource/analyzer-commons-configurations/-/analyzer-commons-configurations-2.31.0-5284.tgz", + "integrity": "sha512-ruk3blMs9BE9RNSp9LPpWZAIjxJmq4Cp00E3cELIHz7Kym3WxdWGRlKOMYLRQ5tJg/de7jN5AtEElY3bGIfTcw==", + "dev": true, + "license": "LGPL-3.0-only" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -5485,6 +5493,346 @@ "@types/node": "*" } }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/@vitest/browser": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.11.tgz", @@ -5692,111 +6040,10 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@voidzero-dev/vite-plus-core": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-core/-/vite-plus-core-0.3.0.tgz", - "integrity": "sha512-aOqoqIWaF+Q/geDU48pC2rVFEVSvLV1GGj/NdvhUiBhCZntoFNbwI+hjUeG8BMaPG67sOV6ey+/sgkdmGmKqaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/runtime": "=0.146.0", - "@oxc-project/types": "=0.146.0", - "lightningcss": "^1.33.0", - "postcss": "^8.5.6", - "yuku-codegen": "^0.5.44", - "yuku-parser": "^0.5.44" - }, - "engines": { - "node": "^20.19.0 || ^22.18.0 || >=24.11.0" - }, - "optionalDependencies": { - "@voidzero-dev/vite-plus-darwin-arm64": "0.3.0", - "@voidzero-dev/vite-plus-darwin-x64": "0.3.0", - "@voidzero-dev/vite-plus-linux-arm64-gnu": "0.3.0", - "@voidzero-dev/vite-plus-linux-arm64-musl": "0.3.0", - "@voidzero-dev/vite-plus-linux-x64-gnu": "0.3.0", - "@voidzero-dev/vite-plus-linux-x64-musl": "0.3.0", - "@voidzero-dev/vite-plus-win32-arm64-msvc": "0.3.0", - "@voidzero-dev/vite-plus-win32-x64-msvc": "0.3.0", - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@arethetypeswrong/core": "^0.18.1", - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.4.0 || ^0.5.0", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "publint": "^0.3.8", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "typescript": "^5.0.0 || ^6.0.0 || ^7.0.0", - "unplugin-unused": "^0.5.0", - "unrun": "*", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@arethetypeswrong/core": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "publint": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "typescript": { - "optional": true - }, - "unplugin-unused": { - "optional": true - }, - "unrun": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, "node_modules/@voidzero-dev/vite-plus-darwin-arm64": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-darwin-arm64/-/vite-plus-darwin-arm64-0.3.0.tgz", - "integrity": "sha512-9ADr1egZ8T4tJOqrpQLhoDl95Y74R95+bsvjmin0gy1C0eQVhpmcNnBfb07KFNhJioJp9MMO7F7Dx4fQL5SKsw==", + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-darwin-arm64/-/vite-plus-darwin-arm64-0.3.2.tgz", + "integrity": "sha512-yQdMXxu1B2Kiv7kmClsu9cehXXlVKmEGM2XUbjFCaObba++Y+wSj1OM5X5rHVLJA8jDvUZHnKTz/Q9m6Hha17A==", "cpu": [ "arm64" ], @@ -5811,9 +6058,9 @@ } }, "node_modules/@voidzero-dev/vite-plus-darwin-x64": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-darwin-x64/-/vite-plus-darwin-x64-0.3.0.tgz", - "integrity": "sha512-GegasVCwNeDOkNyvhLOuwU1+T2JkjY/Tq+SOvwphUpVcqQ6OOAUq9LlpoXviO2QL/Kq2NbMYjiAfPKVSTLUFQw==", + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-darwin-x64/-/vite-plus-darwin-x64-0.3.2.tgz", + "integrity": "sha512-SjiPiT8Z2tV01ds0I1fi/QlStSORXwcjh8ykLNS1kdp11ewqwUQH23ZkmM5KLahbP+6Jg5lA7F+qrO87EUlI3g==", "cpu": [ "x64" ], @@ -5828,9 +6075,9 @@ } }, "node_modules/@voidzero-dev/vite-plus-linux-arm64-gnu": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-linux-arm64-gnu/-/vite-plus-linux-arm64-gnu-0.3.0.tgz", - "integrity": "sha512-nYI3KNYXkXjRPsSdR4Lr7J2xMxfR1+TplWlG/dV37qVXWAjbyHpoAlbULjZBAVJMyXRNlcADhBrEwXe4g6s48A==", + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-linux-arm64-gnu/-/vite-plus-linux-arm64-gnu-0.3.2.tgz", + "integrity": "sha512-lojkdx4xNM9z8hNOMEpWqI690KbtwkpLMzmJD7C+f8IosvkXnyPBi21naB7cuzzcXgUPjqnFxDXtZfNoBCuqbA==", "cpu": [ "arm64" ], @@ -5848,9 +6095,9 @@ } }, "node_modules/@voidzero-dev/vite-plus-linux-arm64-musl": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-linux-arm64-musl/-/vite-plus-linux-arm64-musl-0.3.0.tgz", - "integrity": "sha512-HRlVA3AOcuGXmOdHhQ+Zv5XAaKbYF9si5rRHoOsKl0UyBo4txA3OoJfmP0WjanfLUNmu85JyO2dO1ptL4C6wgg==", + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-linux-arm64-musl/-/vite-plus-linux-arm64-musl-0.3.2.tgz", + "integrity": "sha512-TeM/B64a6jecmT94CaxPaVIEV9kVeb5Ui9+5TYaZTc7soXsxooHvKVexbfbSUoTPqLbArvKvh2dIzSISrJNNiA==", "cpu": [ "arm64" ], @@ -5868,9 +6115,9 @@ } }, "node_modules/@voidzero-dev/vite-plus-linux-x64-gnu": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-linux-x64-gnu/-/vite-plus-linux-x64-gnu-0.3.0.tgz", - "integrity": "sha512-9A+dFScPfwcrzF/rRR0zH8++2hOf6xtFmN/5LyzyfUywtw9MILXcC72IMcOeL6QRJwKUMsudi1rFeDE59azNvw==", + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-linux-x64-gnu/-/vite-plus-linux-x64-gnu-0.3.2.tgz", + "integrity": "sha512-1oIym5BTph9dwc8qCy0oTKLZRMIg+SYG4SucVrlk78IFUdiQC/DTyt34r2V6yIwDeQo4psz9D+zC1hVDulv8DA==", "cpu": [ "x64" ], @@ -5888,9 +6135,9 @@ } }, "node_modules/@voidzero-dev/vite-plus-linux-x64-musl": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-linux-x64-musl/-/vite-plus-linux-x64-musl-0.3.0.tgz", - "integrity": "sha512-KfIV3qaPdaOOE8JQMRHRE34FtZocl9O86XLTP6JMjDUlcx8FPgf8/fz/HFqJ8g232vM+JsgLI/YTVeXP8LkTKw==", + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-linux-x64-musl/-/vite-plus-linux-x64-musl-0.3.2.tgz", + "integrity": "sha512-HCQO92MIRO2vxzKK+19oAcPl5j5EuSsc0m/bwJPIA6eDxJSRwwxpjLi6SwIWWwF+fMeLjBfUUZn6VwWM4aJLmQ==", "cpu": [ "x64" ], @@ -5908,9 +6155,9 @@ } }, "node_modules/@voidzero-dev/vite-plus-win32-arm64-msvc": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-win32-arm64-msvc/-/vite-plus-win32-arm64-msvc-0.3.0.tgz", - "integrity": "sha512-KRhdy5K13AYx9KBfCVHRrK7zSZU+bMW9CL6gTai+UkJgAmDJi1kjdSNboZOjO8mrzUnTCrELgMI2tnstcxSTuA==", + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-win32-arm64-msvc/-/vite-plus-win32-arm64-msvc-0.3.2.tgz", + "integrity": "sha512-XfgtaKa6z2/5emxgNYN+OqU0oMNQpu+NGlltkoYm9P6VHArGXciPPJhJfEyD+457hlfE+hR+ucaW+5j3XCNSyA==", "cpu": [ "arm64" ], @@ -5925,9 +6172,9 @@ } }, "node_modules/@voidzero-dev/vite-plus-win32-x64-msvc": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-win32-x64-msvc/-/vite-plus-win32-x64-msvc-0.3.0.tgz", - "integrity": "sha512-7+G+GxGmxdpQO0zjiGnkZFXKGqm0CrVduebRsJd6ccuOuxCQYPxLcoHq4WOaGrh56SrAGS7XjhnQCrXRkzKUVQ==", + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-win32-x64-msvc/-/vite-plus-win32-x64-msvc-0.3.2.tgz", + "integrity": "sha512-bgCQZbEFs2Hox1szruk4318CSYayqqkFkwSSLzq5UI7kxWNzRGJUHYuC4B/4e2/DwL9qfZphp4ZgLhPvwc973w==", "cpu": [ "x64" ], @@ -5942,15 +6189,15 @@ } }, "node_modules/@wdio/config": { - "version": "9.31.6", - "resolved": "https://registry.npmjs.org/@wdio/config/-/config-9.31.6.tgz", - "integrity": "sha512-+Dk/firI6xPUSsx66Po2nF3CFEjVBC07hHq7K3Vhni/9lxbEO/uAxjyJbYz2ghKhGVHThHtQyggIvZqiBxUUgg==", + "version": "9.31.9", + "resolved": "https://registry.npmjs.org/@wdio/config/-/config-9.31.9.tgz", + "integrity": "sha512-RrEiAJwWVuc8v1sgLFmqMhNoNqXc14UMEXNDEn+U2ihYY+n5M+86tuP9ns06N4SYhnVofjPjly7IZ2Qhadd2cQ==", "dev": true, "license": "MIT", "dependencies": { "@wdio/logger": "9.29.1", "@wdio/types": "9.31.2", - "@wdio/utils": "9.31.6", + "@wdio/utils": "9.31.9", "deepmerge-ts": "^8.0.0", "glob": "^10.2.2", "import-meta-resolve": "^4.0.0", @@ -6011,9 +6258,9 @@ } }, "node_modules/@wdio/utils": { - "version": "9.31.6", - "resolved": "https://registry.npmjs.org/@wdio/utils/-/utils-9.31.6.tgz", - "integrity": "sha512-yPEvnkCrxacYMP5x5EcscFgG5cjaedwJ4tg9aSgQFDWLR0zGUkuCHW95ufYlH1nMgiIhlxSqGvku5h6bjZvzAQ==", + "version": "9.31.9", + "resolved": "https://registry.npmjs.org/@wdio/utils/-/utils-9.31.9.tgz", + "integrity": "sha512-k0Py9L7PxRq8d8/6+B5l4078c12/7lbDzqUVrrhH7VJDV0/EM0qVTtP1LVttLYZEPazdAn0kLSFBCLiiJmfM7Q==", "dev": true, "license": "MIT", "dependencies": { @@ -6022,7 +6269,7 @@ "@wdio/types": "9.31.2", "decamelize": "^6.0.0", "deepmerge-ts": "^8.0.0", - "edgedriver": "^6.1.2", + "edgedriver": "^6.3.1", "geckodriver": "^6.1.1", "get-port": "^7.0.0", "import-meta-resolve": "^4.0.0", @@ -6050,49 +6297,66 @@ "node": ">=18.12.0" } }, + "node_modules/@yuku-codegen/binding-android-arm64": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-android-arm64/-/binding-android-arm64-0.9.5.tgz", + "integrity": "sha512-jrOY5WM+AaAqkv51fHP1x28ifto4WgcZVzodLUyMU1jMWn5Sq+VciRdk/n8E0Ey5w4p4cWWE+m5GfXWOYh7Kzw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, "node_modules/@yuku-codegen/binding-darwin-arm64": { - "version": "0.5.48", - "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-darwin-arm64/-/binding-darwin-arm64-0.5.48.tgz", - "integrity": "sha512-yo96Oef12WzqnphInfz/eexVse3+kWgfGS5g2S3rFS3dcGn1ENW9xLFDZUP9rh+yP76DOq38wBoFi1+I9+6qBg==", + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-darwin-arm64/-/binding-darwin-arm64-0.9.5.tgz", + "integrity": "sha512-4O4lkCQIzPZGjiNB1GORSI6hBECbiiSBS/APZXPvNKYH+nhY4uuqv03LNXA+SET3hoBjvr95P5rIhY8KQaQUBA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@yuku-codegen/binding-darwin-x64": { - "version": "0.5.48", - "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-darwin-x64/-/binding-darwin-x64-0.5.48.tgz", - "integrity": "sha512-aRCTw0EZC4bVosmw//0OMYP5tGWFE0Cu5yUBFkUbhXx/iBzvORcJ2xPNlOp/vtCCo9Ys4vp8b0DigJV6uOVb2g==", + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-darwin-x64/-/binding-darwin-x64-0.9.5.tgz", + "integrity": "sha512-9EvoUO0SEhD6/d8VHdWuPerepPMSR1y84+UEgz8Un1Ope14Oe7xMvePpEQLTLovoIFZ8Zg3iZ8z8E11pZMqC3g==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@yuku-codegen/binding-freebsd-x64": { - "version": "0.5.48", - "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-freebsd-x64/-/binding-freebsd-x64-0.5.48.tgz", - "integrity": "sha512-CA0AQAEApDkbw51PdLWMtKPJ41/7rvXsS3SJs+phG7fHJI+MuFzWuLbkucZfZoEOiDscmcsfYIdgL8BsfuyKKQ==", + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-freebsd-x64/-/binding-freebsd-x64-0.9.5.tgz", + "integrity": "sha512-HqT78WwgHTmp8lwujoUa9CrIortX4DdpuiVC18ZPSGvuuJf4ylpIEI6QrQEM78Zwz3muhAWAOXZ5irdiYY+AyA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" ] }, "node_modules/@yuku-codegen/binding-linux-arm-gnu": { - "version": "0.5.48", - "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-arm-gnu/-/binding-linux-arm-gnu-0.5.48.tgz", - "integrity": "sha512-DuSQlk8bH4gpmW3/00P0NLagAcMv8jOxjT40cQmxKRkktr+SUOALCfkT89tdDq3qtY95NR2GXOZ7AjNh7KKqCw==", + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-arm-gnu/-/binding-linux-arm-gnu-0.9.5.tgz", + "integrity": "sha512-QJXwIW6Ms3QIawbcBIedmrmXnfdpuAOjZJ/eAABq5XTzWvSxZ7lutu9W5yIHahlaTaFnBo7ikrVMD1szLTpPUw==", "cpu": [ "arm" ], @@ -6100,15 +6364,16 @@ "libc": [ "glibc" ], + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@yuku-codegen/binding-linux-arm-musl": { - "version": "0.5.48", - "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-arm-musl/-/binding-linux-arm-musl-0.5.48.tgz", - "integrity": "sha512-bxj4Ee+wlaJcWJwft2ReJXWw5sfl1qavDz6+dlRdU1xfTEtjPSNiAWhiCHnJR0R4Ygd57DnzSQmAVGvFv6RcGw==", + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-arm-musl/-/binding-linux-arm-musl-0.9.5.tgz", + "integrity": "sha512-eHbFy3IHGb+IYarrYwAo0yWSEQV3eAmEn6VrsKA6I1Wy1YPJxWqSJlV69JhqsHtJuJlljUIF3ex0y46yaKIu9w==", "cpu": [ "arm" ], @@ -6116,15 +6381,16 @@ "libc": [ "musl" ], + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@yuku-codegen/binding-linux-arm64-gnu": { - "version": "0.5.48", - "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.5.48.tgz", - "integrity": "sha512-mk5JVWh+0JOe5ue8k17kbYX8uGBoKt3ZqoCyxNh4nYAAcX7+X1tFUiU7jbjctu4vHeejCBFSTdQ021+V31cUCQ==", + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.9.5.tgz", + "integrity": "sha512-ByoJMbySTaDhjAXSu8q6Lh7HKg3YoesXpcT72aYk0Aiw4PCznmY4ybpLTq0RCvp0RIPhFm/6yECFiGBwyCC1nw==", "cpu": [ "arm64" ], @@ -6132,15 +6398,16 @@ "libc": [ "glibc" ], + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@yuku-codegen/binding-linux-arm64-musl": { - "version": "0.5.48", - "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.5.48.tgz", - "integrity": "sha512-4q3vkrNghbllyxOm2KesFLxCPKHF7r3JyQ7BWZccY1j2Y05yKoIFhoWCqIuQ2W/dpte9RI0+OVfwyxnrKg6fkA==", + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.9.5.tgz", + "integrity": "sha512-gD9vfXIoBw1toSxhO9rgmSu/FEfy3PMznJAxVjIuH8DrWEiDKXmJO0pJKfj1Ltbe/TmtWVZR+TjISqeSIGQzMg==", "cpu": [ "arm64" ], @@ -6148,15 +6415,16 @@ "libc": [ "musl" ], + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@yuku-codegen/binding-linux-x64-gnu": { - "version": "0.5.48", - "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.5.48.tgz", - "integrity": "sha512-csd4M1EVrGaohM8acM6gq1zpUA/Rwe2ulUMBKUcwQXm/k6n7cq1A++qdew78SOVb4do3JH1WE+WFwoGQAcWc1w==", + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.9.5.tgz", + "integrity": "sha512-BARvdnvqMGjOr5Iel2JH+9H5vAIE0R6H0Z2fsT02xrvmI/1ZXNB8lkuX+ZGevPhZb3zJ9WeC/R8JZstrsUOK5g==", "cpu": [ "x64" ], @@ -6164,15 +6432,16 @@ "libc": [ "glibc" ], + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@yuku-codegen/binding-linux-x64-musl": { - "version": "0.5.48", - "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-x64-musl/-/binding-linux-x64-musl-0.5.48.tgz", - "integrity": "sha512-KcDuEOT+GFoVKdvAWOv1v9iYjwnmvMZlO+j1Rw+5PYdeFLGWGzv/DD11y4SAAdwXIFcil4T0hibeIaF82WStMg==", + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-x64-musl/-/binding-linux-x64-musl-0.9.5.tgz", + "integrity": "sha512-x8flcevS1fbb7ESrmpOY/pON4eInSaE/7Ktjqx2udOE2W33BNSZmJuwpyUgo54AoHNqrcaM7szqydyJn5fNvew==", "cpu": [ "x64" ], @@ -6180,80 +6449,100 @@ "libc": [ "musl" ], + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@yuku-codegen/binding-win32-arm64": { - "version": "0.5.48", - "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-win32-arm64/-/binding-win32-arm64-0.5.48.tgz", - "integrity": "sha512-HI8qNrI8dWM5BuqIMKsqornRvTNFrE6sm5zToIJ9YIa9zt5+29P7fJ7Nr39EVf6dAWSb6q7JSpScJnRsQ+FgZA==", + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-win32-arm64/-/binding-win32-arm64-0.9.5.tgz", + "integrity": "sha512-KOL/rBatWqH4ZpCNoF8ZtSNdOJbAxBJLmk/VeRciepGROPTbPum1A9t67GpFgOU7qrkUWlPJdJ+KHKbvbmOt+w==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@yuku-codegen/binding-win32-x64": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-win32-x64/-/binding-win32-x64-0.9.5.tgz", + "integrity": "sha512-FxENahEjWSan59Syh/us/Kf4wNq8qrJLFJ3R2N4Oiwtb6yNdz/rWLNTIoNSssnsp7IoWJdUP6o/Z8ppg7lXMcg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, - "node_modules/@yuku-codegen/binding-win32-x64": { - "version": "0.5.48", - "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-win32-x64/-/binding-win32-x64-0.5.48.tgz", - "integrity": "sha512-X5YWJLO6EfBZpeBqO0AYESnUizbpFDWArcvVD61w0PEWQ3CaFRLnbQXs+kpM4ZZfGMfIE22zfA08QSY67q7TNQ==", + "node_modules/@yuku-parser/binding-android-arm64": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-android-arm64/-/binding-android-arm64-0.9.5.tgz", + "integrity": "sha512-A2JCFCSHfnficqYEw4Iujpx7XrkMM3UfFcgJFmYpZSo9zM4nyhmdIIE0FogSduuU60lhM0/UcfuUBXIVNBMlGQ==", "cpu": [ - "x64" + "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ - "win32" + "android" ] }, "node_modules/@yuku-parser/binding-darwin-arm64": { - "version": "0.5.48", - "resolved": "https://registry.npmjs.org/@yuku-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.5.48.tgz", - "integrity": "sha512-If8mb7HH3vqghJ2NNZ8SuHfhsnjVzOxJpB8xcNOXS5WjYrs2mUhHIh5KOIvK13hDOzh0htGeGK3A6MsiEqE7HQ==", + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.9.5.tgz", + "integrity": "sha512-3PiyU+Eare4YuKaQ22N98/yAiROPY5o/NQJHraICzDvk4pgS+m+bgLKOvkGBRn33OnV95Vdv1Mn38b+MQHpULQ==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@yuku-parser/binding-darwin-x64": { - "version": "0.5.48", - "resolved": "https://registry.npmjs.org/@yuku-parser/binding-darwin-x64/-/binding-darwin-x64-0.5.48.tgz", - "integrity": "sha512-EimvPXfspzxf1K11eB6tCW5oiQEXB8g84T2wP1TwzQagdDKo33bkmmVF0B32vTIpXnk/Ifu5IB61izZ1MylljA==", + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-darwin-x64/-/binding-darwin-x64-0.9.5.tgz", + "integrity": "sha512-blFMAFI7AInI83XaiOF8cIeiRM46Nz9EfpZtZPRLbKxSA9aAr5v8aHYpmfN6NoyXxAv/10pwS4gsAuc1W6fy5Q==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@yuku-parser/binding-freebsd-x64": { - "version": "0.5.48", - "resolved": "https://registry.npmjs.org/@yuku-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.5.48.tgz", - "integrity": "sha512-0GcUMrumLHheThY9r5Tp46gaZYzn0irWPS1Zba6WY+vVQfhUtzGiWgXxI6tuXX0N32kEaaEVRpkKctvo6Kx3aQ==", + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.9.5.tgz", + "integrity": "sha512-TsNuL4qsZdO0tHp5GW43Y5fHeLPpPHA675wdcPNTcdq2ZO5AvsQWFFoiDg8CH4oBktfsQ9tdvKhjLnLYwKvp4g==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" ] }, "node_modules/@yuku-parser/binding-linux-arm-gnu": { - "version": "0.5.48", - "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-arm-gnu/-/binding-linux-arm-gnu-0.5.48.tgz", - "integrity": "sha512-8S5T5wjCC73dmmpQeZ49aYsSunIUM3D4Fc6rdK96c+Ayg/p3FmeSPF3xuLZHejcTmqJIIvnbfPlUF+rB6DITjQ==", + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-arm-gnu/-/binding-linux-arm-gnu-0.9.5.tgz", + "integrity": "sha512-b0afYK5gHeV8RdmOcqAlgM8ONsye4cax4DMnIBaoJyPMd1UTGvTbpkqQcPVdhmHlcfcGVGMV1laeVovlr/dscw==", "cpu": [ "arm" ], @@ -6261,15 +6550,16 @@ "libc": [ "glibc" ], + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@yuku-parser/binding-linux-arm-musl": { - "version": "0.5.48", - "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-arm-musl/-/binding-linux-arm-musl-0.5.48.tgz", - "integrity": "sha512-tTmbxvnUHcK2/crS9547vk2SMmsajH1yqJ8ltXhIuHJgqR1v+d9n9KT+kSayo/5CS76LegeYxhMFjEivBH2hFA==", + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-arm-musl/-/binding-linux-arm-musl-0.9.5.tgz", + "integrity": "sha512-fD3lKzl+r6j6n8DwiMY53qnh5DLqD8KJjCp+NbVud54Nnh3Q9Wprqss3YzyMHP3NSJk663Wlg1E1X5qOuFVFig==", "cpu": [ "arm" ], @@ -6277,15 +6567,16 @@ "libc": [ "musl" ], + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@yuku-parser/binding-linux-arm64-gnu": { - "version": "0.5.48", - "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.5.48.tgz", - "integrity": "sha512-KGYCBMqI2zfwyhgq5tpPVNe7jpUeYTBm8DhjdS+zqWNumde/PEC170QE5RHxcOAlsirIDeIUk0jqx+r/axoFSw==", + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.9.5.tgz", + "integrity": "sha512-aRU/aCphV1MWCl/lvI6NX8LgucHQ68Fx+vz7NBb/5MEr2EuzCxiPOQqeFcMdWh+2AED/p0AvAREch0c+cSnlhA==", "cpu": [ "arm64" ], @@ -6293,15 +6584,16 @@ "libc": [ "glibc" ], + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@yuku-parser/binding-linux-arm64-musl": { - "version": "0.5.48", - "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.5.48.tgz", - "integrity": "sha512-2wTSMsCSXLTc2lZUjMAuU5X4cje55u205WJqfV5NWNF6j9pW/tXyxr15dJeekj8ziLqBXzIsj4DbRh4sY/WcjA==", + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.9.5.tgz", + "integrity": "sha512-5+Guro0l8H473YXlEjVNBRLN/IbPbJdnQh1zo0OLt49xxnON+XMJRRaEyTOTfkn9QSRg0+BhEKGR4W9Gu2uZJQ==", "cpu": [ "arm64" ], @@ -6309,15 +6601,16 @@ "libc": [ "musl" ], + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@yuku-parser/binding-linux-x64-gnu": { - "version": "0.5.48", - "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.5.48.tgz", - "integrity": "sha512-d/6v9UnGglVu1WC2JQyv/5aWSi5fXZeGSlidCfmHp4+N65N1GDKUnFtys5MK5eAPeAjTgSHGGtOc/yCcKTlv3A==", + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.9.5.tgz", + "integrity": "sha512-pwwSyV9q+GlvzSXlsMZBMlgk22L5bud714/vqZCdeUTivzeUVltQzUaf3IQXOGXdpc59JYTeuMCtbGquaAUoCA==", "cpu": [ "x64" ], @@ -6325,15 +6618,16 @@ "libc": [ "glibc" ], + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@yuku-parser/binding-linux-x64-musl": { - "version": "0.5.48", - "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.5.48.tgz", - "integrity": "sha512-gX19gw6u4ApPy7SYMPKfFlEkrtj6WlORvrTKK3sBQqjyV+8+mUAkQgxXNjHw4RnOiAmVYg7TOlZcg8d+Qqod9A==", + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.9.5.tgz", + "integrity": "sha512-z18j6JN3lBHH8vzN7gG1M8fI0nlgItSfnC9PfbmUbt97iHViKfw2iA2G9fGwRMe5LyPRZBrejIlapbygPbpdaw==", "cpu": [ "x64" ], @@ -6341,48 +6635,51 @@ "libc": [ "musl" ], + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@yuku-parser/binding-win32-arm64": { - "version": "0.5.48", - "resolved": "https://registry.npmjs.org/@yuku-parser/binding-win32-arm64/-/binding-win32-arm64-0.5.48.tgz", - "integrity": "sha512-w6cQQLbqj3Jcom5Q7ifm103NUOQ9d+Cb4VU5lkrZDjMnwVJ9Hzzg1vCQR7miJuF44vhCXldbme5UryE3giEKlA==", + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-win32-arm64/-/binding-win32-arm64-0.9.5.tgz", + "integrity": "sha512-s6Gwttb1dQvtPX6Bgkw+UPC9IO3UBDXc6Zezog8MMgvu3UfJBBB9TQqKBX/2quu0Eyh+lLSAyNlIyyecaagUPg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@yuku-parser/binding-win32-x64": { - "version": "0.5.48", - "resolved": "https://registry.npmjs.org/@yuku-parser/binding-win32-x64/-/binding-win32-x64-0.5.48.tgz", - "integrity": "sha512-4gO0HmG7fzFxrw1rs0dUdnnaY9YgennjETqDWrTSp7x9fmTUOAoN4VsMfP7YyliQeG1WJJHc55O+rOhmsLppow==", + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-win32-x64/-/binding-win32-x64-0.9.5.tgz", + "integrity": "sha512-FrERt9YWatY3bJfSUdi4YWY+6iQcyo3MzCC821BxlWM5TFZEHijnpz/bknR79VHlXY/9CvXz8ZlaAGPsTtN3nw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@yuku-toolchain/types": { - "version": "0.5.43", - "resolved": "https://registry.npmjs.org/@yuku-toolchain/types/-/types-0.5.43.tgz", - "integrity": "sha512-kSpvPntnXw5+lYjO71ffBEnQ5ycQ74KGIYknh0TS4xeyCuBkOqxyJumxZkMhLBBUCLjDAbx2+Icnr3Zh4ftjpQ==", + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@yuku-toolchain/types/-/types-0.9.5.tgz", + "integrity": "sha512-KiuLNNgX9uNealaWAR+G3/cMXnRk9x4TY2EkYe/KIag+UPdwiA0RRf1hr1WAxzTP8KGzCTkqUdLPvqh32sEO3w==", "dev": true, "license": "MIT" }, "node_modules/@zip.js/zip.js": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.11.4.tgz", - "integrity": "sha512-tk/wizom8aUTkVRNDUhgbXaVI2aYi+XZ07sym/gRb71FVfwYRVufi8dJM2HEeRvfyk0uqghmGCX155OWU3Oy/w==", + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.15.0.tgz", + "integrity": "sha512-hYAuHAaWjt0axbofaDL5XUlmrQPsBDcK3f45ApZuh6N+8UGVk26GoGdtGDvcvzahsHytTQ6DtBVXwG9IcCMjjQ==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -6529,19 +6826,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/anynum": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", - "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, "node_modules/archiver": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", @@ -7695,21 +7979,20 @@ } }, "node_modules/edgedriver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/edgedriver/-/edgedriver-6.3.0.tgz", - "integrity": "sha512-ggEQL+oEyIcM4nP2QC3AtCQ04o4kDNefRM3hja0odvlPSnsaxiruMxEZ93v3gDCKWYW6BXUr51PPradb+3nffw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/edgedriver/-/edgedriver-6.3.1.tgz", + "integrity": "sha512-wCL/Ydgt0DPkWecjhhPj2/lnDxsGeHLPKeJYKKMTNol+ULFVg4WaTvxoB19GU4tieM6LSA3vZEdZxmDC0DwIpQ==", "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { - "@wdio/logger": "^9.18.0", + "@wdio/logger": "^9.29.1", "@zip.js/zip.js": "^2.8.11", "decamelize": "^6.0.1", "edge-paths": "^3.0.5", - "fast-xml-parser": "^5.3.3", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", - "which": "^6.0.0" + "which": "^6.0.1" }, "bin": { "edgedriver": "bin/edgedriver.js" @@ -7853,9 +8136,9 @@ ] }, "node_modules/esbuild": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -7866,32 +8149,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" } }, "node_modules/escalade": { @@ -8001,20 +8284,21 @@ } }, "node_modules/eslint-plugin-sonarjs": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-4.2.0.tgz", - "integrity": "sha512-bqADfuNtTL7VK6RU29eoiFTtaaBKIpVPuX3bOl+rBpWSBa0zIBVZlqZNZQjfP6s4iXkAJokv5IsD8OsACkwApg==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-4.2.1.tgz", + "integrity": "sha512-WGsnVkUxmHyBOytShcIeE+vhQaS8e+gYgbHNNi4y2e5Zj+DpE2DLTvarqDUhNwjceTZHGLgevSgfBSEVkt4EaA==", "dev": true, "license": "LGPL-3.0-only", "dependencies": { "@eslint-community/regexpp": "^4.12.2", + "@sonarsource/analyzer-commons-configurations": "^2.31.0-5284", "builtin-modules": "^3.3.0", "bytes": "^3.1.2", "functional-red-black-tree": "^1.0.1", - "globals": "^17.7.0", + "globals": "^17.12.0", "jsx-ast-utils-x": "^0.1.0", "lodash.merge": "^4.6.2", - "minimatch": "^10.2.5", + "minimatch": "^10.2.6", "scslre": "^0.3.0", "semver": "^7.8.5", "ts-api-utils": "^2.5.0", @@ -8064,6 +8348,33 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/eslint-plugin-sonarjs/node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/eslint-plugin-sonarjs/node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/eslint-scope": { "version": "9.1.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", @@ -8384,9 +8695,9 @@ } }, "node_modules/fast-check": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.0.tgz", - "integrity": "sha512-hhqQL+IJllZi3aM4TKvmCj3bywLEcycNTTLZeLhA9ttMxBrCqM07q7Di4kl+j9EWSTXvJH1+EpIgsDbF/+8H5Q==", + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.1.tgz", + "integrity": "sha512-sB5Vghiu8MyCyToHoBVGsT0baZg3sZWNIY+a6Ct2EDrQJlT4YdH6MC1BSLNe3kX1k5i5g0q1O52XFgcKK/rGHg==", "dev": true, "funding": [ { @@ -8480,47 +8791,6 @@ "fast-string-width": "^3.0.2" } }, - "node_modules/fast-xml-builder": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz", - "integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "path-expression-matcher": "^1.6.2", - "xml-naming": "^0.3.0" - } - }, - "node_modules/fast-xml-parser": { - "version": "5.11.1", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.1.tgz", - "integrity": "sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "@nodable/entities": "^3.0.0", - "fast-xml-builder": "^1.2.0", - "is-unsafe": "^2.0.0", - "path-expression-matcher": "^1.6.2", - "strnum": "^2.4.2", - "xml-naming": "^0.3.0" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, "node_modules/fd-package-json": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz", @@ -9228,9 +9498,9 @@ } }, "node_modules/ip-address": { - "version": "10.7.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", - "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", + "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", "dev": true, "license": "MIT", "engines": { @@ -9334,19 +9604,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-unsafe": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz", - "integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -9481,9 +9738,9 @@ "license": "Python-2.0" }, "node_modules/jscpd": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/jscpd/-/jscpd-5.2.0.tgz", - "integrity": "sha512-6f4qhJGIeR/ZvAkZByfpPw5H28SNEE4fgJ4GXxVuLMiEg7xWrAlUdm0crIJPY6dmykvgdM83ZRrWJXg11fQlYg==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/jscpd/-/jscpd-5.2.1.tgz", + "integrity": "sha512-YNizwOc+NIKgtspq/xrbdURYKTNC9IvIpGWuOZvkEAkdAhevkbdMk1tAC0c1ievSzJd0GjB8PLlCcpCqWF+AEQ==", "dev": true, "license": "MIT", "bin": { @@ -9496,20 +9753,20 @@ "url": "https://opencollective.com/jscpd" }, "optionalDependencies": { - "jscpd-darwin-arm64": "5.2.0", - "jscpd-darwin-x64": "5.2.0", - "jscpd-linux-arm64-gnu": "5.2.0", - "jscpd-linux-arm64-musl": "5.2.0", - "jscpd-linux-x64-gnu": "5.2.0", - "jscpd-linux-x64-musl": "5.2.0", - "jscpd-windows-arm64-msvc": "5.2.0", - "jscpd-windows-x64-msvc": "5.2.0" + "jscpd-darwin-arm64": "5.2.1", + "jscpd-darwin-x64": "5.2.1", + "jscpd-linux-arm64-gnu": "5.2.1", + "jscpd-linux-arm64-musl": "5.2.1", + "jscpd-linux-x64-gnu": "5.2.1", + "jscpd-linux-x64-musl": "5.2.1", + "jscpd-windows-arm64-msvc": "5.2.1", + "jscpd-windows-x64-msvc": "5.2.1" } }, "node_modules/jscpd-darwin-arm64": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/jscpd-darwin-arm64/-/jscpd-darwin-arm64-5.2.0.tgz", - "integrity": "sha512-QnEDfTH2MymizVHiEQpqfYEj63k1DW7QC0QBZoevh+o/iHQrHxKlgrakMysUIwIJlnmvqPB6iP/G3dBpFmnmeQ==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/jscpd-darwin-arm64/-/jscpd-darwin-arm64-5.2.1.tgz", + "integrity": "sha512-svftqL0o7EA0Of0Qo9LbwOE4IrIAoFP87yfmPML/ScqZkDwyezL4ggH//aXULKrOPQWLwkTgbSqM0OgmxfOpyg==", "cpu": [ "arm64" ], @@ -9521,9 +9778,9 @@ ] }, "node_modules/jscpd-darwin-x64": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/jscpd-darwin-x64/-/jscpd-darwin-x64-5.2.0.tgz", - "integrity": "sha512-P2lYEK0Yyn+0z7618+9A0NErywjaPrwSYcId3gRSt5l0cyKBwhMmyFLX/2t3G2HRi+GX+OT2A/YpWvvTzLJJ1A==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/jscpd-darwin-x64/-/jscpd-darwin-x64-5.2.1.tgz", + "integrity": "sha512-m9AM+/LgtgUsGKGWmZdK82IgqEIATF6z2LJX+CmODGYw8WlBkji3ZdNicuHbGyviBZLaFsSGx1Axy+WFhmQUsA==", "cpu": [ "x64" ], @@ -9535,9 +9792,9 @@ ] }, "node_modules/jscpd-linux-arm64-gnu": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/jscpd-linux-arm64-gnu/-/jscpd-linux-arm64-gnu-5.2.0.tgz", - "integrity": "sha512-UhdX9vvCFwoG1cViNqFyvxfqNtJ+xWbGsgZ5PbcgUx2OLWnvjhkjihPw/fOUM9IoxHaT66AE5pipaV/oQE/76A==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/jscpd-linux-arm64-gnu/-/jscpd-linux-arm64-gnu-5.2.1.tgz", + "integrity": "sha512-W/9KvjikBfzhDtszY089W66krrx5hG7eO57hZ+uzQ71ij9XRb5FV9n1pGNBivw9xjZKghgsoBraWjAkZ87jq8w==", "cpu": [ "arm64" ], @@ -9552,9 +9809,9 @@ ] }, "node_modules/jscpd-linux-arm64-musl": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/jscpd-linux-arm64-musl/-/jscpd-linux-arm64-musl-5.2.0.tgz", - "integrity": "sha512-13DsZCQ58fOMUX6mU8DZtiTdIk+9Gp2xLu6mtc6fmqqZRJoRHQSSDvMqRVedCqArquxc0I+tqQtZO4n7/8QJxA==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/jscpd-linux-arm64-musl/-/jscpd-linux-arm64-musl-5.2.1.tgz", + "integrity": "sha512-tjQcXmOAnXBXaciPZuVGZndSAEXbwO29+ugkJZbwTAtO9m16MdMKuQz86iT1peneJlNl/k/Et8gs0jFRO2MS+Q==", "cpu": [ "arm64" ], @@ -9569,9 +9826,9 @@ ] }, "node_modules/jscpd-linux-x64-gnu": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/jscpd-linux-x64-gnu/-/jscpd-linux-x64-gnu-5.2.0.tgz", - "integrity": "sha512-p88BpA5QzyZzyF8uYeVCz9ZBoZYg8s6AwcDc3NkIDNTbrrF0sKqgGjclGWAoJvYAiGExiFNqV768pVHsE//l0Q==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/jscpd-linux-x64-gnu/-/jscpd-linux-x64-gnu-5.2.1.tgz", + "integrity": "sha512-t/lRefzbkHCycEpmCRlYSFYSNjw85lr58yWV3ZH9PCMt7Bb/neNCn/20YI1V6RroWFg5/VyU66Sv//fEw+h5Ow==", "cpu": [ "x64" ], @@ -9586,9 +9843,9 @@ ] }, "node_modules/jscpd-linux-x64-musl": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/jscpd-linux-x64-musl/-/jscpd-linux-x64-musl-5.2.0.tgz", - "integrity": "sha512-6DgIxJpS7L4bb1DkYrqC0SflMY2/2/3Dl0kZJ1In6SeSENcTaZ12W1zX3kNf7wVUDFgPi94TuSxrw3N2TqRhRg==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/jscpd-linux-x64-musl/-/jscpd-linux-x64-musl-5.2.1.tgz", + "integrity": "sha512-2GQWdb0JVug32tb00MH0vtjQyN7PAmVlHuQ/8IEn+/lcxSU6Zcf132+ggrDQdTxHxBevo9GnzapaC/It+7kgyw==", "cpu": [ "x64" ], @@ -9603,9 +9860,9 @@ ] }, "node_modules/jscpd-windows-arm64-msvc": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/jscpd-windows-arm64-msvc/-/jscpd-windows-arm64-msvc-5.2.0.tgz", - "integrity": "sha512-DI84oCG+L5sYhmYanXjjydXlFzC194/NJA6F7W8cmOI1rKSnWrgIjUtvTmXumygwTK/eAXkK7rFBqFnyiLEzdw==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/jscpd-windows-arm64-msvc/-/jscpd-windows-arm64-msvc-5.2.1.tgz", + "integrity": "sha512-FUVOZ7Qlhvj7A8bYjpHjAJC7AU2kCioVUAPoD9wFk5xpOkLcZdRtCjgLUpTkczRVUvalrdBHi7UuwmjVuMjZxA==", "cpu": [ "arm64" ], @@ -9617,9 +9874,9 @@ ] }, "node_modules/jscpd-windows-x64-msvc": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/jscpd-windows-x64-msvc/-/jscpd-windows-x64-msvc-5.2.0.tgz", - "integrity": "sha512-EH4chck2EIehosfy7q6hxaKgzlaZv/dZhUHtjBmBf81d5/ZDfa6BsKw2gM8PSoNC0QXznWXv9uQcFWsV+P9v8A==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/jscpd-windows-x64-msvc/-/jscpd-windows-x64-msvc-5.2.1.tgz", + "integrity": "sha512-N4qo1cpcGCxf+HS4KS/Ue7qUozi/ndm60DIbQy25YYaAy/8LGI2F3KInaerebmW0MzV8sMMC3PcggKwztnsKbw==", "cpu": [ "x64" ], @@ -9766,9 +10023,9 @@ } }, "node_modules/knip": { - "version": "6.35.1", - "resolved": "https://registry.npmjs.org/knip/-/knip-6.35.1.tgz", - "integrity": "sha512-22wnEnv4do2fvoeJsxpFCG/MBxReNxoCVBccaCl7suUJsG+F0UvxI9ycxZ9YgtLHSSjrZl5tOas0JZ/R1vJ93g==", + "version": "6.36.0", + "resolved": "https://registry.npmjs.org/knip/-/knip-6.36.0.tgz", + "integrity": "sha512-DWETufDYX4UdaLeCK9gI5rfROanxVxbrsyEPzHFIrrn1H+W04f3XdQ6HPWMnA7O2u2VOKOLWt+EV23K/kb5jlA==", "dev": true, "funding": [ { @@ -10722,13 +10979,13 @@ } }, "node_modules/oxfmt": { - "version": "0.64.0", - "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.64.0.tgz", - "integrity": "sha512-XZ4GFBN/PLbXKq+0zrgpQfPKYuJlUuj+nzZJY7UpIbFMNyefNLCdN9EwViycNqnYcv0wrn0jXcQLlqJp8RCKBg==", + "version": "0.67.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.67.0.tgz", + "integrity": "sha512-vV7sSiPsaO0mSxdoUdayipVDFPzW/UQ+hrezEHa20+Tx1dnMdZLSRHMT0PdS67FFbhd74M1n08asW21aLGeCrA==", "dev": true, "license": "MIT", "dependencies": { - "tinypool": "2.1.0" + "tinypool": "2.1.2" }, "bin": { "oxfmt": "bin/oxfmt" @@ -10737,28 +10994,28 @@ "node": "^20.19.0 || >=22.12.0" }, "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" }, "optionalDependencies": { - "@oxfmt/binding-android-arm-eabi": "0.64.0", - "@oxfmt/binding-android-arm64": "0.64.0", - "@oxfmt/binding-darwin-arm64": "0.64.0", - "@oxfmt/binding-darwin-x64": "0.64.0", - "@oxfmt/binding-freebsd-x64": "0.64.0", - "@oxfmt/binding-linux-arm-gnueabihf": "0.64.0", - "@oxfmt/binding-linux-arm-musleabihf": "0.64.0", - "@oxfmt/binding-linux-arm64-gnu": "0.64.0", - "@oxfmt/binding-linux-arm64-musl": "0.64.0", - "@oxfmt/binding-linux-ppc64-gnu": "0.64.0", - "@oxfmt/binding-linux-riscv64-gnu": "0.64.0", - "@oxfmt/binding-linux-riscv64-musl": "0.64.0", - "@oxfmt/binding-linux-s390x-gnu": "0.64.0", - "@oxfmt/binding-linux-x64-gnu": "0.64.0", - "@oxfmt/binding-linux-x64-musl": "0.64.0", - "@oxfmt/binding-openharmony-arm64": "0.64.0", - "@oxfmt/binding-win32-arm64-msvc": "0.64.0", - "@oxfmt/binding-win32-ia32-msvc": "0.64.0", - "@oxfmt/binding-win32-x64-msvc": "0.64.0" + "@oxfmt/binding-android-arm-eabi": "0.67.0", + "@oxfmt/binding-android-arm64": "0.67.0", + "@oxfmt/binding-darwin-arm64": "0.67.0", + "@oxfmt/binding-darwin-x64": "0.67.0", + "@oxfmt/binding-freebsd-x64": "0.67.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.67.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.67.0", + "@oxfmt/binding-linux-arm64-gnu": "0.67.0", + "@oxfmt/binding-linux-arm64-musl": "0.67.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.67.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.67.0", + "@oxfmt/binding-linux-riscv64-musl": "0.67.0", + "@oxfmt/binding-linux-s390x-gnu": "0.67.0", + "@oxfmt/binding-linux-x64-gnu": "0.67.0", + "@oxfmt/binding-linux-x64-musl": "0.67.0", + "@oxfmt/binding-openharmony-arm64": "0.67.0", + "@oxfmt/binding-win32-arm64-msvc": "0.67.0", + "@oxfmt/binding-win32-ia32-msvc": "0.67.0", + "@oxfmt/binding-win32-x64-msvc": "0.67.0" }, "peerDependencies": { "svelte": "^5.0.0", @@ -10774,9 +11031,9 @@ } }, "node_modules/oxlint": { - "version": "1.79.0", - "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.79.0.tgz", - "integrity": "sha512-hVJ9hq9m2unPS+Of4eJJgCPdIeCC+3DHEUX3tkmrPJr3OK2hz7PhXwgC+ZP71ZcYu8cCDEtQrqLxWNvxBppBVg==", + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.82.0.tgz", + "integrity": "sha512-+iFM1BGw1ntYJt3QngbJmjbrGxPaKMUADOXOijpWGnYcBPq8YZnQftSS1C+pVcDYy9YxqDVJKQqQkTazTQMboQ==", "dev": true, "license": "MIT", "bin": { @@ -10786,28 +11043,28 @@ "node": "^20.19.0 || >=22.12.0" }, "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" }, "optionalDependencies": { - "@oxlint/binding-android-arm-eabi": "1.79.0", - "@oxlint/binding-android-arm64": "1.79.0", - "@oxlint/binding-darwin-arm64": "1.79.0", - "@oxlint/binding-darwin-x64": "1.79.0", - "@oxlint/binding-freebsd-x64": "1.79.0", - "@oxlint/binding-linux-arm-gnueabihf": "1.79.0", - "@oxlint/binding-linux-arm-musleabihf": "1.79.0", - "@oxlint/binding-linux-arm64-gnu": "1.79.0", - "@oxlint/binding-linux-arm64-musl": "1.79.0", - "@oxlint/binding-linux-ppc64-gnu": "1.79.0", - "@oxlint/binding-linux-riscv64-gnu": "1.79.0", - "@oxlint/binding-linux-riscv64-musl": "1.79.0", - "@oxlint/binding-linux-s390x-gnu": "1.79.0", - "@oxlint/binding-linux-x64-gnu": "1.79.0", - "@oxlint/binding-linux-x64-musl": "1.79.0", - "@oxlint/binding-openharmony-arm64": "1.79.0", - "@oxlint/binding-win32-arm64-msvc": "1.79.0", - "@oxlint/binding-win32-ia32-msvc": "1.79.0", - "@oxlint/binding-win32-x64-msvc": "1.79.0" + "@oxlint/binding-android-arm-eabi": "1.82.0", + "@oxlint/binding-android-arm64": "1.82.0", + "@oxlint/binding-darwin-arm64": "1.82.0", + "@oxlint/binding-darwin-x64": "1.82.0", + "@oxlint/binding-freebsd-x64": "1.82.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.82.0", + "@oxlint/binding-linux-arm-musleabihf": "1.82.0", + "@oxlint/binding-linux-arm64-gnu": "1.82.0", + "@oxlint/binding-linux-arm64-musl": "1.82.0", + "@oxlint/binding-linux-ppc64-gnu": "1.82.0", + "@oxlint/binding-linux-riscv64-gnu": "1.82.0", + "@oxlint/binding-linux-riscv64-musl": "1.82.0", + "@oxlint/binding-linux-s390x-gnu": "1.82.0", + "@oxlint/binding-linux-x64-gnu": "1.82.0", + "@oxlint/binding-linux-x64-musl": "1.82.0", + "@oxlint/binding-openharmony-arm64": "1.82.0", + "@oxlint/binding-win32-arm64-msvc": "1.82.0", + "@oxlint/binding-win32-ia32-msvc": "1.82.0", + "@oxlint/binding-win32-x64-msvc": "1.82.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", @@ -11038,22 +11295,6 @@ "node": ">=8" } }, - "node_modules/path-expression-matcher": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", - "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -12149,22 +12390,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strnum": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz", - "integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "anynum": "^1.0.1" - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -12274,9 +12499,9 @@ } }, "node_modules/tinypool": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.0.tgz", - "integrity": "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.2.tgz", + "integrity": "sha512-9YodfrxS9g9IbFr/KOjE5bAeJ0p61n3bW6mqvy0jtoeKd1kTW1Cxm0oulm6KX2lyM9Gl6WIe8nEbY7LWv5ZJww==", "dev": true, "license": "MIT", "engines": { @@ -12313,19 +12538,6 @@ "tree-kill": "cli.js" } }, - "node_modules/ts-api-utils": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", - "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -12398,17 +12610,38 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", "dev": true, "license": "Apache-2.0", "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "tsc": "bin/tsc" }, "engines": { - "node": ">=14.17" + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" } }, "node_modules/unbash": { @@ -12633,13 +12866,13 @@ } }, "node_modules/vite-plus": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/vite-plus/-/vite-plus-0.3.0.tgz", - "integrity": "sha512-GNWbWuWD37frCSFrz6MLzUo62bTv5IOJozHEgZYOkxsLkuQtTwm4TowzpfoGrSsfwhAAtfPd/sK1Y0+v1SwhZA==", + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/vite-plus/-/vite-plus-0.3.2.tgz", + "integrity": "sha512-mCUZaRlFEFBWlwaDjk81mkD2WX9KQKBboHX+uJPMb41JHaDfzjxnxgmiViuQJm2B+FL4prDkDYYYZtHP422jDA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.146.0", + "@oxc-project/types": "=0.149.0", "@oxlint/plugins": "=1.79.0", "@vitest/browser": "4.1.11", "@vitest/browser-preview": "4.1.11", @@ -12650,10 +12883,10 @@ "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", - "@voidzero-dev/vite-plus-core": "0.3.0", - "oxfmt": "=0.64.0", - "oxlint": "=1.79.0", + "oxfmt": "=0.67.0", + "oxlint": "=1.82.0", "oxlint-tsgolint": "=7.0.2001", + "vite": "npm:@voidzero-dev/vite-plus-core@0.3.2", "vitest": "4.1.11" }, "bin": { @@ -12666,14 +12899,14 @@ "node": "^20.19.0 || ^22.18.0 || >=24.11.0" }, "optionalDependencies": { - "@voidzero-dev/vite-plus-darwin-arm64": "0.3.0", - "@voidzero-dev/vite-plus-darwin-x64": "0.3.0", - "@voidzero-dev/vite-plus-linux-arm64-gnu": "0.3.0", - "@voidzero-dev/vite-plus-linux-arm64-musl": "0.3.0", - "@voidzero-dev/vite-plus-linux-x64-gnu": "0.3.0", - "@voidzero-dev/vite-plus-linux-x64-musl": "0.3.0", - "@voidzero-dev/vite-plus-win32-arm64-msvc": "0.3.0", - "@voidzero-dev/vite-plus-win32-x64-msvc": "0.3.0" + "@voidzero-dev/vite-plus-darwin-arm64": "0.3.2", + "@voidzero-dev/vite-plus-darwin-x64": "0.3.2", + "@voidzero-dev/vite-plus-linux-arm64-gnu": "0.3.2", + "@voidzero-dev/vite-plus-linux-arm64-musl": "0.3.2", + "@voidzero-dev/vite-plus-linux-x64-gnu": "0.3.2", + "@voidzero-dev/vite-plus-linux-x64-musl": "0.3.2", + "@voidzero-dev/vite-plus-win32-arm64-msvc": "0.3.2", + "@voidzero-dev/vite-plus-win32-x64-msvc": "0.3.2" }, "peerDependencies": { "@vitest/browser-playwright": "4.1.11", @@ -12688,6 +12921,108 @@ } } }, + "node_modules/vite-plus/node_modules/vite": { + "name": "@voidzero-dev/vite-plus-core", + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-core/-/vite-plus-core-0.3.2.tgz", + "integrity": "sha512-CdwhIci2dg2XOtSVQ1QBhBF+BVvFQR9ub6YZaBsFes6KdjoD43Ws75o2UCTQco6TRQ2gzBEHBbCCMWkH9jsBJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/runtime": "=0.149.0", + "@oxc-project/types": "=0.149.0", + "lightningcss": "^1.33.0", + "postcss": "^8.5.6", + "yuku-codegen": "^0.9.3", + "yuku-parser": "^0.9.3" + }, + "engines": { + "node": "^20.19.0 || ^22.18.0 || >=24.11.0" + }, + "optionalDependencies": { + "@voidzero-dev/vite-plus-darwin-arm64": "0.3.2", + "@voidzero-dev/vite-plus-darwin-x64": "0.3.2", + "@voidzero-dev/vite-plus-linux-arm64-gnu": "0.3.2", + "@voidzero-dev/vite-plus-linux-arm64-musl": "0.3.2", + "@voidzero-dev/vite-plus-linux-x64-gnu": "0.3.2", + "@voidzero-dev/vite-plus-linux-x64-musl": "0.3.2", + "@voidzero-dev/vite-plus-win32-arm64-msvc": "0.3.2", + "@voidzero-dev/vite-plus-win32-x64-msvc": "0.3.2", + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@arethetypeswrong/core": "^0.18.1", + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "publint": "^0.3.8", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "typescript": "^5.0.0 || ^6.0.0 || ^7.0.0", + "unplugin-unused": ">=0.5.0", + "unrun": "*", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@arethetypeswrong/core": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "publint": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "typescript": { + "optional": true + }, + "unplugin-unused": { + "optional": true + }, + "unrun": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, "node_modules/vitest": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", @@ -12847,19 +13182,19 @@ "license": "Apache-2.0" }, "node_modules/webdriver": { - "version": "9.31.6", - "resolved": "https://registry.npmjs.org/webdriver/-/webdriver-9.31.6.tgz", - "integrity": "sha512-4l+G2vu7PqVFXlUbMPZ7ueX95o2TgivTjvyBUAUSbllb8qUHIchBC26dscC4LRU44mAfBk1+GmZ6YkG9c0vxCA==", + "version": "9.31.9", + "resolved": "https://registry.npmjs.org/webdriver/-/webdriver-9.31.9.tgz", + "integrity": "sha512-mkRevz4ZIHCBe09FtHQnWg0G/p40bqk3Rv7aBooSthc8hpKG0880B9iga08drtPSiYN6/wC59WBh10ooLPboJA==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "^20.1.0", "@types/ws": "^8.5.3", - "@wdio/config": "9.31.6", + "@wdio/config": "9.31.9", "@wdio/logger": "9.29.1", "@wdio/protocols": "9.31.5", "@wdio/types": "9.31.2", - "@wdio/utils": "9.31.6", + "@wdio/utils": "9.31.9", "deepmerge-ts": "^8.0.0", "https-proxy-agent": "^7.0.6", "undici": "^6.27.0", @@ -12880,20 +13215,20 @@ } }, "node_modules/webdriverio": { - "version": "9.31.6", - "resolved": "https://registry.npmjs.org/webdriverio/-/webdriverio-9.31.6.tgz", - "integrity": "sha512-5TUI4meKkGiNrkhClXmQVbr0r1SBq3nSuxrut58QMbYrqFQ5xjPCDV7OTqNUC3CydojRkWkVapUGmxG88ELwEQ==", + "version": "9.31.9", + "resolved": "https://registry.npmjs.org/webdriverio/-/webdriverio-9.31.9.tgz", + "integrity": "sha512-C5Uxgry8Gcs4HJ2Ln/9m8QK55S4s5qNw6bjyFnWIFaZhPaoLrC0YN6wSzZ/LqfnysPdefRjgHxctFX/G6xIi0Q==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "^20.11.30", "@types/sinonjs__fake-timers": "^8.1.5", - "@wdio/config": "9.31.6", + "@wdio/config": "9.31.9", "@wdio/logger": "9.29.1", "@wdio/protocols": "9.31.5", "@wdio/repl": "9.16.2", "@wdio/types": "9.31.2", - "@wdio/utils": "9.31.6", + "@wdio/utils": "9.31.9", "archiver": "^7.0.1", "aria-query": "^5.3.0", "cheerio": "^1.0.0-rc.12", @@ -12910,7 +13245,7 @@ "rgb2hex": "0.2.5", "serialize-error": "^12.0.0", "urlpattern-polyfill": "^10.0.0", - "webdriver": "9.31.6" + "webdriver": "9.31.9" }, "engines": { "node": ">=18.20.0" @@ -13122,22 +13457,6 @@ } } }, - "node_modules/xml-naming": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", - "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=16.0.0" - } - }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -13276,50 +13595,63 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yuku-ast": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/yuku-ast/-/yuku-ast-0.9.5.tgz", + "integrity": "sha512-Q8qW8WwQnN5Cm0ZZivdRIfv0sRLTjUq0YumXJkw8CYN1aCdICH9rk4C47/4n6kA5EqDtlj+F608twoTSV2MwdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yuku-toolchain/types": "^0.9.5" + } + }, "node_modules/yuku-codegen": { - "version": "0.5.48", - "resolved": "https://registry.npmjs.org/yuku-codegen/-/yuku-codegen-0.5.48.tgz", - "integrity": "sha512-p7HxD5Xl4jzDzqMrGePAOeSHmRY4g58h4HuGq15weQFPxuPWd/W6e7nqp/+Lea6JfpOdBwJOAyXFqIZ/J9Zfnw==", + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/yuku-codegen/-/yuku-codegen-0.9.5.tgz", + "integrity": "sha512-zGUVyDpSK4b6c9B0yyxi2P0wujn1XZTbG9dCb7d6gyAoP63EMgyLzUwPUvwxPG5jgYqhlI7w+S3oCyapqGr4PA==", "dev": true, "license": "MIT", "dependencies": { - "@yuku-toolchain/types": "0.5.43" + "@yuku-toolchain/types": "^0.9.5" }, "optionalDependencies": { - "@yuku-codegen/binding-darwin-arm64": "0.5.48", - "@yuku-codegen/binding-darwin-x64": "0.5.48", - "@yuku-codegen/binding-freebsd-x64": "0.5.48", - "@yuku-codegen/binding-linux-arm-gnu": "0.5.48", - "@yuku-codegen/binding-linux-arm-musl": "0.5.48", - "@yuku-codegen/binding-linux-arm64-gnu": "0.5.48", - "@yuku-codegen/binding-linux-arm64-musl": "0.5.48", - "@yuku-codegen/binding-linux-x64-gnu": "0.5.48", - "@yuku-codegen/binding-linux-x64-musl": "0.5.48", - "@yuku-codegen/binding-win32-arm64": "0.5.48", - "@yuku-codegen/binding-win32-x64": "0.5.48" + "@yuku-codegen/binding-android-arm64": "0.9.5", + "@yuku-codegen/binding-darwin-arm64": "0.9.5", + "@yuku-codegen/binding-darwin-x64": "0.9.5", + "@yuku-codegen/binding-freebsd-x64": "0.9.5", + "@yuku-codegen/binding-linux-arm-gnu": "0.9.5", + "@yuku-codegen/binding-linux-arm-musl": "0.9.5", + "@yuku-codegen/binding-linux-arm64-gnu": "0.9.5", + "@yuku-codegen/binding-linux-arm64-musl": "0.9.5", + "@yuku-codegen/binding-linux-x64-gnu": "0.9.5", + "@yuku-codegen/binding-linux-x64-musl": "0.9.5", + "@yuku-codegen/binding-win32-arm64": "0.9.5", + "@yuku-codegen/binding-win32-x64": "0.9.5" } }, "node_modules/yuku-parser": { - "version": "0.5.48", - "resolved": "https://registry.npmjs.org/yuku-parser/-/yuku-parser-0.5.48.tgz", - "integrity": "sha512-OWBfhrpgK9+/4+IXG9oT8Bao4AhViQA7vdyNNH7EUg8dQYgwa70XtIBWTpCEme1P1ECyoDNYkn0wT63f8XRcVA==", + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/yuku-parser/-/yuku-parser-0.9.5.tgz", + "integrity": "sha512-IBnAdNVMswWbJcM7woPluOudectJzFjJ1N8jVRxP9Uq4CIv5hZdBIcdmtRbFDYF/Ph2j2gVup4YJ4N9mh0jYFA==", "dev": true, "license": "MIT", "dependencies": { - "@yuku-toolchain/types": "0.5.43" + "@yuku-toolchain/types": "^0.9.5", + "yuku-ast": "^0.9.5" }, "optionalDependencies": { - "@yuku-parser/binding-darwin-arm64": "0.5.48", - "@yuku-parser/binding-darwin-x64": "0.5.48", - "@yuku-parser/binding-freebsd-x64": "0.5.48", - "@yuku-parser/binding-linux-arm-gnu": "0.5.48", - "@yuku-parser/binding-linux-arm-musl": "0.5.48", - "@yuku-parser/binding-linux-arm64-gnu": "0.5.48", - "@yuku-parser/binding-linux-arm64-musl": "0.5.48", - "@yuku-parser/binding-linux-x64-gnu": "0.5.48", - "@yuku-parser/binding-linux-x64-musl": "0.5.48", - "@yuku-parser/binding-win32-arm64": "0.5.48", - "@yuku-parser/binding-win32-x64": "0.5.48" + "@yuku-parser/binding-android-arm64": "0.9.5", + "@yuku-parser/binding-darwin-arm64": "0.9.5", + "@yuku-parser/binding-darwin-x64": "0.9.5", + "@yuku-parser/binding-freebsd-x64": "0.9.5", + "@yuku-parser/binding-linux-arm-gnu": "0.9.5", + "@yuku-parser/binding-linux-arm-musl": "0.9.5", + "@yuku-parser/binding-linux-arm64-gnu": "0.9.5", + "@yuku-parser/binding-linux-arm64-musl": "0.9.5", + "@yuku-parser/binding-linux-x64-gnu": "0.9.5", + "@yuku-parser/binding-linux-x64-musl": "0.9.5", + "@yuku-parser/binding-win32-arm64": "0.9.5", + "@yuku-parser/binding-win32-x64": "0.9.5" } }, "node_modules/zip-stream": { diff --git a/package.json b/package.json index d0d32757e..011fb953f 100644 --- a/package.json +++ b/package.json @@ -1,21 +1,30 @@ { "name": "@brazilian-utils/brazilian-utils", "version": "2.3.0", - "description": "Brazilian Utils is a library focused on solving problems that we face daily in the development of applications for the Brazilian business.", + "description": "Zero-dependency, tree-shakeable TypeScript utilities for Brazilian data: validate, format, parse and generate CPF, CNPJ, CEP, boleto, Pix, phone, holidays and more.", "keywords": [ "boleto", "brasil", "brazil", "brazilian", + "cbo", "cep", + "cfop", + "cnae", "cnh", "cnpj", "cpf", + "esm", + "feriados", "formatter", "generator", "holidays", + "iban", + "ibge", "ie", "license-plate", + "municipios", + "ncm", "nfe", "phone", "pis", @@ -38,6 +47,7 @@ "url": "git+https://github.com/brazilian-utils/javascript.git" }, "files": [ + "./CHANGELOG.md", "./dist" ], "type": "module", @@ -45,6 +55,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,13 +116,14 @@ "check:duplication": "jscpd", "check:unused": "knip", "test:mutation": "stryker run", - "check:api": "npm run build && api-extractor run --local --verbose", + "bench": "vp test bench --run", + "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", "build:llms": "node ./scripts/llms.ts", "prepublishOnly": "vp run build", - "ci": "vp check", "check:dependencies": "node -e \"const d=require('./package.json').dependencies||{};if(Object.keys(d).length){console.error('runtime dependencies are not allowed:',Object.keys(d).join(', '));process.exit(1)}\"" }, "devDependencies": { @@ -111,20 +135,26 @@ "@stryker-mutator/vitest-runner": "10.0.0", "@vitest/browser-webdriverio": "4.1.11", "@vitest/coverage-v8": "4.1.11", - "esbuild": "0.27.2", - "eslint-plugin-sonarjs": "^4.2.0", - "fast-check": "4.10.0", - "jscpd": "5.2.0", - "knip": "6.35.1", + "esbuild": "0.28.2", + "eslint-plugin-sonarjs": "^4.2.1", + "fast-check": "4.10.1", + "jscpd": "5.2.1", + "knip": "6.36.0", "lockfile-lint": "5.0.1", "publint": "0.3.24", - "typescript": "5.9.3", - "vite-plus": "0.3.0", - "webdriverio": "9.31.6" + "typescript": "7.0.2", + "vite-plus": "0.3.2", + "webdriverio": "9.31.9" }, "overrides": { "basic-ftp@5": "^5.3.1", "brace-expansion@2": "^2.1.2", + "eslint-plugin-sonarjs": { + "typescript": "6.0.3", + "ts-api-utils": { + "typescript": "6.0.3" + } + }, "fast-xml-builder@1": "^1.1.7", "ip-address@10": "^10.3.1", "nanoid@3": "^3.3.16", diff --git a/release-please-config.json b/release-please-config.json index fc2cdc16c..35182664a 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -11,10 +11,10 @@ { "type": "perf", "section": "Performance" }, { "type": "revert", "section": "Reverts" }, { "type": "docs", "section": "Documentation" }, - { "type": "build", "section": "Build System" }, - { "type": "ci", "section": "CI" }, - { "type": "deps", "section": "Dependencies" }, - { "type": "chore", "scope": "deps", "section": "Dependencies" }, + { "type": "build", "section": "Build System", "hidden": true }, + { "type": "ci", "section": "CI", "hidden": true }, + { "type": "chore", "scope": "deps", "section": "Dependencies", "hidden": true }, + { "type": "chore", "scope": "deps-dev", "section": "Dependencies", "hidden": true }, { "type": "chore", "scope": "data", "section": "Data" }, { "type": "chore", "section": "Miscellaneous", "hidden": true }, { "type": "test", "section": "Tests", "hidden": true }, diff --git a/reports/api/brazilian-utils.api.md b/reports/api/brazilian-utils.api.md new file mode 100644 index 000000000..31135c54f --- /dev/null +++ b/reports/api/brazilian-utils.api.md @@ -0,0 +1,1158 @@ +## 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: (params?: GenerateBoletoParams) => string; + +// @public +export type GenerateBoletoParams = { + type?: "bancario" | "arrecadacao"; +}; + +// @public +export const generateCep: () => string; + +// @public +export const generateCnh: () => string; + +// @public @deprecated +export const generateCNPJ: typeof generateCnpj; + +// @public +export const generateCnpj: (versionOrParams?: 1 | 2 | GenerateCnpjParams) => string; + +// @public +export type GenerateCnpjParams = { + 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: GeneratePixPayloadParams) => string | null; + +// @public +export type GeneratePixPayloadParams = { + key?: string; + url?: string; + merchantName: string; + merchantCity: string; + amount?: number; + txid?: string; + description?: string; +}; + +// @public +export const generateProcessoJuridico: (options?: GenerateProcessoJuridicoParams) => string | null; + +// @public @deprecated +export type GenerateProcessoJuridicoOptions = GenerateProcessoJuridicoParams; + +// @public +export type GenerateProcessoJuridicoParams = { + 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: GetCepInfoByAddressParams) => Promise; + +// @public +export class GetCepInfoByAddressError extends Error { + constructor(message: string); +} + +// @public +export class GetCepInfoByAddressNotFoundError extends GetCepInfoByAddressError { + constructor(message: string); +} + +// @public @deprecated +export type GetCepInfoByAddressOptions = GetCepInfoByAddressParams; + +// @public +export type GetCepInfoByAddressParams = { + 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: GetHolidaysParams): Holiday[]; + +// @public @deprecated +export type GetHolidaysOptions = GetHolidaysParams; + +// @public +export type GetHolidaysParams = { + 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: (params?: GetLegalNaturesParams) => Record; + +// @public +export const getLegalNaturesByCategory: (category: string | number, options?: GetLegalNaturesByCategoryOptions) => LegalNature[]; + +// @public +export type GetLegalNaturesByCategoryOptions = { + includeLegacy?: boolean; +}; + +// @public +export type GetLegalNaturesParams = { + includeLegacy?: boolean; +}; + +// @public +export const getMunicipalities: (stateCode?: StateCode) => Municipality[]; + +// @public @deprecated +export function getMunicipality(options: GetMunicipalityByCodeParams): Promise<[string, string] | null>; + +// @public @deprecated +export function getMunicipality(options: GetMunicipalityByNameParams): Promise; + +// @public @deprecated +export function getMunicipality(options: GetMunicipalityParams): Promise<[string, string] | string | null>; + +// @public +export const getMunicipalityByCode: (code: string | number) => Municipality | null; + +// @public @deprecated +export type GetMunicipalityByCodeOptions = GetMunicipalityByCodeParams; + +// @public +export type GetMunicipalityByCodeParams = { + code: string | number; +}; + +// @public @deprecated +export type GetMunicipalityByNameOptions = GetMunicipalityByNameParams; + +// @public +export type GetMunicipalityByNameParams = { + municipalityName: string; + uf: string; +}; + +// @public @deprecated +export type GetMunicipalityOptions = GetMunicipalityParams; + +// @public +export type GetMunicipalityParams = GetMunicipalityByCodeParams | GetMunicipalityByNameParams; + +// @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?: IsHolidayParams) => boolean; + +// @public @deprecated +export type IsHolidayOptions = IsHolidayParams; + +// @public +export type IsHolidayParams = { + targetDate: Date; + stateCode?: StateCode; +}; + +// @public +export const isValidBankAccount: (params: IsValidBankAccountParams) => boolean; + +// @public @deprecated +export type IsValidBankAccountOptions = IsValidBankAccountParams; + +// @public +export type IsValidBankAccountParams = { + bankCode: string; + agency: string; + account: string; + digit: string; +}; + +// @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 function isValidIe(params: IsValidIeParams): boolean; + +// @public @deprecated +export function isValidIe(stateCode: StateCode, ie: string): boolean; + +// @public +export type IsValidIeParams = { + value: string; + stateCode: StateCode; +}; + +// @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: IsValidRegistroProfissionalParams) => boolean; + +// @public +export type IsValidRegistroProfissionalParams = { + 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; +} & ({ + legacy: false; +} | { + legacy: true; + currentCode: string | null; +}); + +// @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 31674705b..8de6a6ce2 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/cbo.ts b/scripts/cbo.ts index 41d2fdcc6..64414d484 100644 --- a/scripts/cbo.ts +++ b/scripts/cbo.ts @@ -3,7 +3,7 @@ import { writeFile } from "node:fs/promises"; import { resolve } from "node:path"; -import { fetchSortedRecord } from "../src/_internals/fetch-sorted-record/fetch-sorted-record.ts"; +import { fetchSortedRecord } from "./fetch-sorted-record.ts"; const scriptsDir = import.meta.dirname; diff --git a/scripts/cfop.ts b/scripts/cfop.ts index bc616d463..87ae66c3c 100644 --- a/scripts/cfop.ts +++ b/scripts/cfop.ts @@ -3,7 +3,7 @@ import { writeFile } from "node:fs/promises"; import { resolve } from "node:path"; -import { fetchSortedRecord } from "../src/_internals/fetch-sorted-record/fetch-sorted-record.ts"; +import { fetchSortedRecord } from "./fetch-sorted-record.ts"; const scriptsDir = import.meta.dirname; @@ -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 864ecd4e2..ead9e7bc7 100644 --- a/scripts/cnae.ts +++ b/scripts/cnae.ts @@ -3,7 +3,7 @@ import { writeFile } from "node:fs/promises"; import { resolve } from "node:path"; -import { fetchWithRetry } from "../src/_internals/fetch-with-retry/fetch-with-retry.ts"; +import { fetchSortedRecord } from "./fetch-sorted-record.ts"; const scriptsDir = import.meta.dirname; @@ -21,38 +21,44 @@ const isCnaeSubclass = (value: unknown): value is CnaeSubclass => typeof value.descricao === "string"; const main = async (): Promise => { - const response = await fetchWithRetry("https://servicodados.ibge.gov.br/api/v2/cnae/subclasses"); + const data = await fetchSortedRecord( + "https://servicodados.ibge.gov.br/api/v2/cnae/subclasses", + "IBGE CNAE", + async (response) => { + const json: unknown = await response.json(); - if (!response.ok) { - throw new Error(`IBGE CNAE request failed with status ${response.status}`); - } + if (!Array.isArray(json) || !json.every((entry) => isCnaeSubclass(entry))) { + throw new Error("IBGE CNAE payload is not an array of subclass entries"); + } - const json: unknown = await response.json(); - - if (!Array.isArray(json) || !json.every((entry) => isCnaeSubclass(entry))) { - throw new Error("IBGE CNAE payload is not an array of subclass entries"); - } - - const entries = json - .filter((subclass) => /^\d{7}$/.test(subclass.id)) - .sort((subclassA, subclassB) => (subclassA.id > subclassB.id ? 1 : -1)) - .map((subclass) => [subclass.id, subclass.descricao] as const); - - const data: Record = {}; - for (const [id, descricao] of entries) { - data[id] = descricao; - } + return Object.fromEntries( + json + .filter((subclass) => /^\d{7}$/.test(subclass.id)) + .map((subclass) => [subclass.id, subclass.descricao]), + ); + }, + ); 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 52c403806..850c569d0 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/src/_internals/fetch-sorted-record/fetch-sorted-record.ts b/scripts/fetch-sorted-record.ts similarity index 92% rename from src/_internals/fetch-sorted-record/fetch-sorted-record.ts rename to scripts/fetch-sorted-record.ts index 9cbeda347..9d969eb27 100644 --- a/src/_internals/fetch-sorted-record/fetch-sorted-record.ts +++ b/scripts/fetch-sorted-record.ts @@ -1,4 +1,4 @@ -import { fetchWithRetry } from "../fetch-with-retry/fetch-with-retry.ts"; +import { fetchWithRetry } from "../src/_internals/fetch-with-retry/fetch-with-retry.ts"; /** * Fetches a dataset with retry, fails when the response is not ok, and returns the parsed diff --git a/scripts/legal-natures.ts b/scripts/legal-natures.ts index c5c404287..fafc2a202 100644 --- a/scripts/legal-natures.ts +++ b/scripts/legal-natures.ts @@ -18,15 +18,41 @@ const OUTPUT_PATH = "./src/is-valid-legal-nature/constants.ts"; const EXPECTED_CODES = 92; -const LEGACY_LEGAL_NATURE: Record = { - "2076": "Sociedade Empresária em Nome Coletivo", - "2100": "Sociedade Mercantil de Capital e Indústria (extinta pelo NCC/2002)", - "2208": "Entidade Binacional Itaipu", - "3042": "Organização Social", - "3050": "Organização da Sociedade Civil de Interesse Público (Oscip)", - "3093": "Unidade Executora (Programa Dinheiro Direto na Escola)", - "3123": "Partido Político", - "5002": "Organização Internacional e Outras Instituições Extraterritoriais", +const CORRESPONDENCE_PAGE_URL = + "https://concla.ibge.gov.br/classificacoes/correspondencias/natureza-juridica.html"; + +const CORRESPONDENCE_2003_2009_URL = + "https://concla.ibge.gov.br/images/concla/documentacao/correspTNJ2003(1)-2009.xls"; + +const CORRESPONDENCE_1995_2003_URL = + "https://concla.ibge.gov.br/images/concla/documentacao/correspTNJ1995-2002-2003.xls"; + +/** + * The codes a past revision of the table retired, with the code the CONCLA correspondence + * spreadsheets map each one to (`null` when the code was retired without a successor). They are + * kept in the shipped table because they still appear in records filed while they were in force. + */ +const LEGACY_LEGAL_NATURE: Record = { + "2076": { description: "Sociedade Empresária em Nome Coletivo", currentCode: "2070" }, + "2100": { + description: "Sociedade Mercantil de Capital e Indústria (extinta pelo NCC/2002)", + currentCode: null, + }, + "2208": { description: "Entidade Binacional Itaipu", currentCode: "2275" }, + "3042": { description: "Organização Social", currentCode: "3069" }, + "3050": { + description: "Organização da Sociedade Civil de Interesse Público (Oscip)", + currentCode: null, + }, + "3093": { + description: "Unidade Executora (Programa Dinheiro Direto na Escola)", + currentCode: "3999", + }, + "3123": { description: "Partido Político", currentCode: null }, + "5002": { + description: "Organização Internacional e Outras Instituições Extraterritoriais", + currentCode: "5010", + }, }; const TYPO_FIXES: Record = { @@ -69,40 +95,39 @@ const unescapePdfString = (value: string): string => return char ?? ""; }); -const extractBracketText = (body: string): string => { +/** + * Concatenates every string a PDF text operator carries, unescaped. + * @param {string} source - The fragment of the content stream to read. + * @param {RegExp} pattern - The global pattern whose first group is one escaped string. + * @returns {string} The strings of every match, unescaped and joined. + */ +const collectStrings = (source: string, pattern: RegExp): string => { let text = ""; - for (const array of body.matchAll(/\[((?:[^[\]\\]|\\.)*)\]\s*TJ/g)) { - const arrayContent = array[1]; - - if (arrayContent === undefined) continue; - - for (const chunk of arrayContent.matchAll(/\(((?:[^()\\]|\\.)*)\)/g)) { - const chunkText = chunk[1]; - - if (chunkText === undefined) continue; + for (const [, chunkText] of source.matchAll(pattern)) { + if (chunkText === undefined) continue; - text += unescapePdfString(chunkText); - } + text += unescapePdfString(chunkText); } return text; }; -const extractParenthesizedText = (body: string): string => { +const extractBracketText = (body: string): string => { let text = ""; - for (const chunk of body.matchAll(/\(((?:[^()\\]|\\.)*)\)\s*Tj/g)) { - const chunkText = chunk[1]; - - if (chunkText === undefined) continue; + for (const [, arrayContent] of body.matchAll(/\[((?:[^[\]\\]|\\.)*)\]\s*TJ/g)) { + if (arrayContent === undefined) continue; - text += unescapePdfString(chunkText); + text += collectStrings(arrayContent, /\(((?:[^()\\]|\\.)*)\)/g); } return text; }; +const extractParenthesizedText = (body: string): string => + collectStrings(body, /\(((?:[^()\\]|\\.)*)\)\s*Tj/g); + const extractLines = (streams: string[]): string[] => { const lines: string[] = []; @@ -133,8 +158,7 @@ const extractLines = (streams: string[]): string[] => { rows.set(y, row); } - for (const y of [...rows.keys()].sort((a, b) => b - a)) { - const row = rows.get(y) ?? []; + for (const [, row] of [...rows].sort(([a], [b]) => b - a)) { lines.push( row .sort(([a], [b]) => a - b) @@ -169,9 +193,9 @@ const parseLegalNatures = (lines: string[]): Record => { return legalNatures; }; -const stringifyEntries = (entries: Record): string => +const stringifyEntries = (entries: Record): string => Object.entries(entries) - .map(([code, description]) => `\t${JSON.stringify(code)}: ${JSON.stringify(description)},`) + .map(([code, value]) => `\t${JSON.stringify(code)}: ${JSON.stringify(value)},`) .join("\n"); const main = async (): Promise => { @@ -193,6 +217,26 @@ const main = async (): Promise => { Object.entries(LEGACY_LEGAL_NATURE).filter(([code]) => !(code in current)), ); + const legacyCodes = Object.keys(legacy); + + const legacyDescriptions = Object.fromEntries( + Object.entries(legacy).map(([code, { description }]) => [code, description]), + ); + + const legacyCurrentCodes = Object.fromEntries( + Object.entries(legacy).map(([code, { currentCode }]) => [code, currentCode]), + ); + + for (const [code, currentCode] of Object.entries(legacyCurrentCodes)) { + if (currentCode !== null && !(currentCode in current)) { + throw new Error(`Legacy legal nature ${code} maps to the unknown code ${currentCode}`); + } + } + + const typoFixedCodes = Object.entries(current) + .filter(([, description]) => Object.values(TYPO_FIXES).includes(description)) + .map(([code]) => code); + await writeFile( resolve(scriptsDir, "..", OUTPUT_PATH), `/** @@ -200,13 +244,52 @@ 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 a past revision + * retired, mapped to the code they correspond to today by \`LEGACY_LEGAL_NATURE\` and still + * accepted because they keep appearing in records filed while they were in force. 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)} +${stringifyEntries(legacyDescriptions)} +}; + +/** + * The code each legacy legal nature code corresponds to today, or \`null\` when the revision that + * retired it published no successor, indexed by the legacy code. + * + * Generated by \`node ./scripts/legal-natures.ts\`. Do not edit by hand. + * + * The mapping is the one the CONCLA correspondence spreadsheets publish. 2076 is the 2003 spelling + * of the code the 2003.1 revision renumbered to 2070, under the same denomination; 2208 (Empresa + * Binacional Itaipu) became 2275 (Empresa Binacional); 3042 (Organização Social) became 3069 + * (Fundação Privada), and the 2014 revision later created 3301 (Organização Social (OS)), where an + * entity qualified as one is classified today; 3093 became 3999; and 5002 was opened into 5010, + * 5029 and 5037, with 5010 published as its correspondence. The other three have none: 2100 is + * marked "categoria extinta", 3050 (Oscip) has an empty correspondence because an Oscip is + * classified by the form it takes (3999 or 3069), and 3123 (Partido Político), still in the 2009 + * table, was dropped by the 2014 one, which split it into 3255, 3263 and 3271 without publishing a + * correspondence. + * + * The CONCLA pages sit behind a bot filter and answer HTTP 403 to every non-browser client, so + * they have to be opened in a browser; the spreadsheets next to them are served normally. + * + * @see Official: ${CORRESPONDENCE_PAGE_URL} + * @see Official: ${CORRESPONDENCE_2003_2009_URL} + * @see Official: ${CORRESPONDENCE_1995_2003_URL} + */ +export const LEGACY_LEGAL_NATURE: Record = { +${stringifyEntries(legacyCurrentCodes)} }; `, ); diff --git a/scripts/llms.ts b/scripts/llms.ts index 22214905e..66ef2d445 100644 --- a/scripts/llms.ts +++ b/scripts/llms.ts @@ -72,29 +72,112 @@ 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("**", "")); +} + +const UTIL_HEADING_PATTERN = /^#{2,3} ([a-z][A-Za-z0-9]*)\n/; + /** - * Parses every `## ` section of `utilities.md` into name/slug/description. + * Parses every function section of `utilities.md` into name/slug/description. A function section + * starts with a `## ` or `### ` heading whose text is a bare identifier; the `##` family + * headings that group most of them ("CPF", "Pix", ...) are skipped. * @param {string} utilitiesMd - The full contents of `utilities.md`. - * @returns {UtilSection[]} One entry per `## ` section, in document order. + * @returns {UtilSection[]} One entry per function section, in document order. */ function parseUtilities(utilitiesMd: string): UtilSection[] { - const sections = utilitiesMd.split(/^## /m).slice(1); + const sections = utilitiesMd + .split(/^(?=#{2,3} )/m) + .filter((section) => UTIL_HEADING_PATTERN.test(section)); return sections.map((section) => { const newlineIndex = section.indexOf("\n"); - const name = section.slice(0, newlineIndex).trim(); + const name = section.slice(0, newlineIndex).replace(/^#+ /, "").trim(); 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] ?? ""; + + return section + .split("\n") + .filter((row) => row.startsWith("| `")) + .flatMap((row) => + [...(row.split("|")[1] ?? "").matchAll(BACKTICKED_PATTERN)].map(([, name]) => name), + ); +} + +/** + * 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 +215,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 +233,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 +243,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} @@ -190,17 +273,13 @@ function stripDocsifySyntax(markdown: string): string { } /** - * Demotes every markdown heading in `markdown` by `levels` (adds `#`s), so it nests under a + * Demotes every markdown heading in `markdown` by one level (adds one `#`), so it nests under a * higher-level heading. * @param {string} markdown - The Markdown whose headings should be demoted. - * @param {number} levels - How many `#`s to add to each heading. - * @returns {string} `markdown` with every heading demoted by `levels`. + * @returns {string} `markdown` with every heading demoted by one level. */ -function demoteHeadings(markdown: string, levels: number): string { - return markdown.replaceAll( - /^(#{1,5})(\s)/gm, - (_match, hashes: string, space: string) => `${"#".repeat(hashes.length + levels)}${space}`, - ); +function demoteHeadings(markdown: string): string { + return markdown.replaceAll(/^(#{1,5}\s)/gm, "#$1"); } function buildLlmsFullTxt( @@ -210,15 +289,13 @@ 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"); - const gettingStarted = demoteHeadings(stripDocsifySyntax(gettingStartedMd), 1); - const utilities = demoteHeadings(stripDocsifySyntax(utilitiesMd), 1); + const gettingStarted = demoteHeadings(stripDocsifySyntax(gettingStartedMd)); + const utilities = demoteHeadings(stripDocsifySyntax(utilitiesMd)); return `# Brazilian Utils @@ -239,7 +316,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 8f8bca5c9..26687ad5c 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/states.ts b/scripts/states.ts index 16854b854..aa1fed470 100644 --- a/scripts/states.ts +++ b/scripts/states.ts @@ -106,6 +106,22 @@ export type StateName = State["name"]; */ export const DATA: readonly State[] = ${JSON.stringify(states)};`, ); + + await writeFile( + resolve(scriptsDir, "..", "./src/_internals/constants/state-codes.ts"), + `import { type StateCode } from "./states"; + +/** + * The two letter code of each Brazilian state published by the IBGE, in the order of \`DATA\` in + * \`./states\` (sorted by state name in the "pt-BR" locale), on its own so that a util which only + * needs the codes does not carry the whole states table into a consumer's bundle. + * + * Generated by \`node ./scripts/states.ts\`. Do not edit by hand. + * + * @see Official: https://servicodados.ibge.gov.br/api/docs/localidades + */ +export const STATE_CODES: readonly StateCode[] = ${JSON.stringify(states.map((state) => state.code))};`, + ); }; await main().catch((error) => { diff --git a/scripts/tree-shaking.ts b/scripts/tree-shaking.ts index cb8e2a586..9e6f87eec 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__"; @@ -83,7 +86,6 @@ type CompareRow = { type CompareResult = { changed: CompareRow[]; - unchanged: CompareRow[]; added: (Measurement & { name: string })[]; removed: (Measurement & { name: string })[]; fullDeltaBytes: number; @@ -128,18 +130,18 @@ const mapWithConcurrency = async ( let cursor = 0; const worker = async (): Promise => { - const index = cursor; - cursor += 1; + while (cursor < items.length) { + const index = cursor; + cursor += 1; - if (index >= items.length) return; + const item = items[index]; - const item = items[index]; - - if (item !== undefined) { - results[index] = await fn(item); + if (item !== undefined) { + // Each worker of the pool processes its items one after the other on purpose. + // eslint-disable-next-line no-await-in-loop + results[index] = await fn(item); + } } - - await worker(); }; const workers = Array.from({ length: Math.min(limit, items.length) }, () => worker()); @@ -215,15 +217,15 @@ const loadExports = async ( const aliasOf = new Map(); for (const group of groups.values()) { if (group.length < 2) continue; - const sorted = [...group].sort(); - const target = sorted.at(-1); + // `group` is built from the already sorted `functionExports`, so it is sorted too. + const target = group.at(-1); if (target === undefined) continue; - for (const name of sorted.slice(0, -1)) aliasOf.set(name, target); + for (const name of group.slice(0, -1)) aliasOf.set(name, target); } - const testable = functionExports.filter((name) => !aliasOf.has(name)).sort(); + const testable = functionExports.filter((name) => !aliasOf.has(name)); return { testable, aliasOf }; }; @@ -250,7 +252,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); @@ -269,8 +271,9 @@ const measureExports = async ( throw new Error("No measurements produced"); } - const exportsMap: Record = {}; - for (const m of measurements) exportsMap[m.name] = { bytes: m.bytes, gzip: m.gzip }; + const exportsMap: Record = Object.fromEntries( + measurements.map((m) => [m.name, { bytes: m.bytes, gzip: m.gzip }]), + ); for (const [alias, target] of aliasOf) { const targetMeasurement: Measurement | undefined = exportsMap[target]; if (targetMeasurement !== undefined) exportsMap[alias] = targetMeasurement; @@ -311,7 +314,6 @@ const printTable = ( const compareSnapshots = (base: Snapshot, head: Snapshot, existing: Measurement): CompareResult => { const names = new Set([...Object.keys(base.exports), ...Object.keys(head.exports)]); const changed: CompareRow[] = []; - const unchanged: CompareRow[] = []; const added: (Measurement & { name: string })[] = []; const removed: (Measurement & { name: string })[] = []; @@ -337,7 +339,7 @@ const compareSnapshots = (base: Snapshot, head: Snapshot, existing: Measurement) deltaBytes, deltaPercent, }; - (deltaBytes === 0 ? unchanged : changed).push(row); + if (deltaBytes !== 0) changed.push(row); } changed.sort((a, b) => Math.abs(b.deltaBytes) - Math.abs(a.deltaBytes)); @@ -355,7 +357,6 @@ const compareSnapshots = (base: Snapshot, head: Snapshot, existing: Measurement) return { changed, - unchanged, added, removed, fullDeltaBytes, @@ -516,7 +517,7 @@ const renderMarkdown = ( "| | Base | Head | Δ |", "| --- | ---: | ---: | ---: |", `| Pre-existing exports, all imported | ${formatBytes((base.surviving ?? base.full).bytes)} | ${formatBytes(existing.bytes)} (gzip ${formatBytes(existing.gzip)}) | ${result.fullImportRegressed ? "🔴 " : ""}${formatDelta(result.fullDeltaBytes, result.fullDeltaPercent)} |`, - `| Full import | ${formatBytes(base.full.bytes)} | ${formatBytes(head.full.bytes)} (gzip ${formatBytes(head.full.gzip)}) | ${formatDelta(head.full.bytes - base.full.bytes, base.full.bytes === 0 ? 0 : (head.full.bytes - base.full.bytes) / base.full.bytes)} |`, + `| Full import | ${formatBytes(base.full.bytes)} | ${formatBytes(head.full.bytes)} (gzip ${formatBytes(head.full.gzip)}) | ${formatRowDelta(base.full, head.full)} |`, `| Exports | ${Object.keys(base.exports).length} | ${measured} | ${formatCount(measured - Object.keys(base.exports).length)} |`, "", ...renderRows("What changed", EXPORT_COLUMNS, changedRows), @@ -579,8 +580,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 fddb580ad..000000000 --- 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 24950f1f5..74800c63a 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-cnh-first-verifier/calculate-cnh-first-verifier.ts b/src/_internals/calculate-cnh-first-verifier/calculate-cnh-first-verifier.ts index 276a0c0aa..04707c415 100644 --- a/src/_internals/calculate-cnh-first-verifier/calculate-cnh-first-verifier.ts +++ b/src/_internals/calculate-cnh-first-verifier/calculate-cnh-first-verifier.ts @@ -19,6 +19,8 @@ export type CnhFirstVerifier = { * ``` */ export const calculateCnhFirstVerifier = (base: string): CnhFirstVerifier => { + // The weighted sum is written out rather than delegated to the shared `generateChecksum`: its + // sanitizer chain costs `isValidCnh` and `generateCnh` around 240 B of bundle each. let sum = 0; for (let i = 0; i < 9; i++) { diff --git a/src/_internals/calculate-cnh-second-verifier/calculate-cnh-second-verifier.ts b/src/_internals/calculate-cnh-second-verifier/calculate-cnh-second-verifier.ts index 17e0a4085..4d291e2f0 100644 --- a/src/_internals/calculate-cnh-second-verifier/calculate-cnh-second-verifier.ts +++ b/src/_internals/calculate-cnh-second-verifier/calculate-cnh-second-verifier.ts @@ -23,6 +23,8 @@ export const calculateCnhSecondVerifier = ({ base, decrement, }: CalculateCnhSecondVerifierParams): number => { + // Written out rather than delegated to the shared `generateChecksum`, for the reason given in + // `calculateCnhFirstVerifier`. let sum = 0; for (let i = 0; i < 9; i++) { diff --git a/src/_internals/calculate-cnpj-check-digit/calculate-cnpj-check-digit.test.ts b/src/_internals/calculate-cnpj-check-digit/calculate-cnpj-check-digit.test.ts new file mode 100644 index 000000000..4cdcf225e --- /dev/null +++ b/src/_internals/calculate-cnpj-check-digit/calculate-cnpj-check-digit.test.ts @@ -0,0 +1,34 @@ +import { CNPJ_FIRST_DIGIT_WEIGHTS, CNPJ_SECOND_DIGIT_WEIGHTS } from "../constants/cnpj"; +import { describe, expect, test } from "../test/runtime"; +import { calculateCnpjCheckDigit } from "./calculate-cnpj-check-digit"; + +describe("calculateCnpjCheckDigit", () => { + test("should return the two check digits of the numeric CNPJ 12345678000195", () => { + expect(calculateCnpjCheckDigit("123456780001", CNPJ_FIRST_DIGIT_WEIGHTS)).toBe(9); + expect(calculateCnpjCheckDigit("1234567800019", CNPJ_SECOND_DIGIT_WEIGHTS)).toBe(5); + }); + + test("should return the two check digits of the alphanumeric CNPJ Q0SLFMBD7VX439", () => { + expect(calculateCnpjCheckDigit("Q0SLFMBD7VX4", CNPJ_FIRST_DIGIT_WEIGHTS)).toBe(3); + expect(calculateCnpjCheckDigit("Q0SLFMBD7VX43", CNPJ_SECOND_DIGIT_WEIGHTS)).toBe(9); + }); + + test("should read only as many characters as there are weights", () => { + expect(calculateCnpjCheckDigit("12345678000195", CNPJ_FIRST_DIGIT_WEIGHTS)).toBe(9); + expect(calculateCnpjCheckDigit("12345678000195", CNPJ_SECOND_DIGIT_WEIGHTS)).toBe(5); + }); + + test("should return 0 when the weighted sum leaves a remainder of 0 or 1", () => { + expect(calculateCnpjCheckDigit("000000000000", CNPJ_FIRST_DIGIT_WEIGHTS)).toBe(0); + expect(calculateCnpjCheckDigit("000000000006", CNPJ_FIRST_DIGIT_WEIGHTS)).toBe(0); + }); + + test("should return 9 when the weighted sum leaves a remainder of 2", () => { + expect(calculateCnpjCheckDigit("000000000001", CNPJ_FIRST_DIGIT_WEIGHTS)).toBe(9); + }); + + test("should weigh each position by its own weight", () => { + expect(calculateCnpjCheckDigit("100000000000", CNPJ_FIRST_DIGIT_WEIGHTS)).toBe(6); + expect(calculateCnpjCheckDigit("1000000000000", CNPJ_SECOND_DIGIT_WEIGHTS)).toBe(5); + }); +}); diff --git a/src/_internals/calculate-cnpj-check-digit/calculate-cnpj-check-digit.ts b/src/_internals/calculate-cnpj-check-digit/calculate-cnpj-check-digit.ts new file mode 100644 index 000000000..5f93bf635 --- /dev/null +++ b/src/_internals/calculate-cnpj-check-digit/calculate-cnpj-check-digit.ts @@ -0,0 +1,35 @@ +const MODULUS = 11; + +/** + * Calculates a check digit of a CNPJ (Cadastro Nacional da Pessoa Jurídica) base, under the rule + * both CNPJ versions share. + * + * Each character of the base is read as its code point minus 48, which is the digit itself for + * `0` to `9` and the value the alphanumeric CNPJ assigns to `A` to `Z` (17 to 42), so the numeric + * version goes through the very same calculation instead of a second, digits-only one. Each value + * is multiplied by the weight at its position, and the check digit is 11 minus the remainder of + * the weighted sum by 11, or 0 when that remainder is 0 or 1. + * + * @param {string} base - The characters that precede the check digit, at least as long as `weights`. + * @param {readonly number[]} weights - The weight of each character of the base, from left to right. + * @returns {number} The check digit, 0 to 9. + * + * @example + * ```typescript + * calculateCnpjCheckDigit("123456780001", [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]); // 9 + * calculateCnpjCheckDigit("Q0SLFMBD7VX4", [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]); // 3 + * ``` + * + * @see Official: https://www.gov.br/receitafederal/pt-br/centrais-de-conteudo/publicacoes/documentos-tecnicos/cnpj/manual-dv-cnpj.pdf + */ +export const calculateCnpjCheckDigit = (base: string, weights: readonly number[]): number => { + let sum = 0; + + for (let index = 0; index < weights.length; index++) { + sum += (base.charCodeAt(index) - 48) * weights[index]; + } + + const remainder = sum % MODULUS; + + return remainder < 2 ? 0 : MODULUS - remainder; +}; diff --git a/src/_internals/calculate-cpf-check-digit/calculate-cpf-check-digit.test.ts b/src/_internals/calculate-cpf-check-digit/calculate-cpf-check-digit.test.ts new file mode 100644 index 000000000..120c8c2c4 --- /dev/null +++ b/src/_internals/calculate-cpf-check-digit/calculate-cpf-check-digit.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "../test/runtime"; +import { calculateCpfCheckDigit } from "./calculate-cpf-check-digit"; + +describe("calculateCpfCheckDigit", () => { + test("should return the two check digits of 12345678909", () => { + expect(calculateCpfCheckDigit("123456789")).toBe(0); + expect(calculateCpfCheckDigit("1234567890")).toBe(9); + }); + + test("should return the two check digits of 52998224725", () => { + expect(calculateCpfCheckDigit("529982247")).toBe(2); + expect(calculateCpfCheckDigit("5299822472")).toBe(5); + }); + + test("should return 0 when the weighted sum leaves a remainder of 0 or 1", () => { + expect(calculateCpfCheckDigit("000000000")).toBe(0); + expect(calculateCpfCheckDigit("123456789")).toBe(0); + }); + + test("should return 9 when the weighted sum leaves a remainder of 2", () => { + expect(calculateCpfCheckDigit("000000001")).toBe(9); + }); + + test("should return 1 when the weighted sum leaves a remainder of 10", () => { + expect(calculateCpfCheckDigit("111111111")).toBe(1); + }); + + test("should weigh the leftmost digit by one more than the length of the base", () => { + expect(calculateCpfCheckDigit("100000000")).toBe(1); + expect(calculateCpfCheckDigit("1000000000")).toBe(0); + }); +}); diff --git a/src/_internals/calculate-cpf-check-digit/calculate-cpf-check-digit.ts b/src/_internals/calculate-cpf-check-digit/calculate-cpf-check-digit.ts new file mode 100644 index 000000000..2a16fae8d --- /dev/null +++ b/src/_internals/calculate-cpf-check-digit/calculate-cpf-check-digit.ts @@ -0,0 +1,35 @@ +const MODULUS = 11; + +/** + * Calculates a check digit of a CPF (Cadastro de Pessoas Físicas) base. + * + * Each digit of the base is multiplied by a weight that starts one above the length of the base + * and decreases down to 2, so the 9 digit base of the first check digit weighs 10 to 2 and the + * 10 digit base (the first 9 digits plus the first check digit) of the second one weighs 11 to + * 2. The check digit is 11 minus the remainder of the weighted sum by 11, or 0 when that + * remainder is 0 or 1. + * + * @param {string} base - The 9 or 10 digits that precede the check digit. + * @returns {number} The check digit, 0 to 9. + * + * @example + * ```typescript + * calculateCpfCheckDigit("123456789"); // 0 + * calculateCpfCheckDigit("1234567890"); // 9 + * ``` + * + * @see Official: https://www.gov.br/receitafederal/pt-br/assuntos/orientacao-tributaria/cadastros/cpf + */ +export const calculateCpfCheckDigit = (base: string): number => { + let sum = 0; + let weight = base.length + 1; + + for (let index = 0; index < base.length; index++) { + sum += (base.charCodeAt(index) - 48) * weight; + weight--; + } + + const remainder = sum % MODULUS; + + return remainder < 2 ? 0 : MODULUS - remainder; +}; diff --git a/src/_internals/calculate-pis-check-digit/calculate-pis-check-digit.test.ts b/src/_internals/calculate-pis-check-digit/calculate-pis-check-digit.test.ts new file mode 100644 index 000000000..794eb1a7c --- /dev/null +++ b/src/_internals/calculate-pis-check-digit/calculate-pis-check-digit.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "../test/runtime"; +import { calculatePisCheckDigit } from "./calculate-pis-check-digit"; + +describe("calculatePisCheckDigit", () => { + test("should return 0 when the weighted sum is a multiple of 11 (base of 12345678900)", () => { + expect(calculatePisCheckDigit("1234567890")).toBe(0); + expect(calculatePisCheckDigit("0000000000")).toBe(0); + }); + + test("should return 1 when the weighted sum leaves a remainder of 10 (base of 00000000051)", () => { + expect(calculatePisCheckDigit("0000000005")).toBe(1); + }); + + test("should return 9 when the weighted sum leaves a remainder of 2 (base of 00000000019)", () => { + expect(calculatePisCheckDigit("0000000001")).toBe(9); + }); + + test("should weigh the leftmost digit by 3 and the rightmost by 2", () => { + expect(calculatePisCheckDigit("1000000000")).toBe(8); + expect(calculatePisCheckDigit("0000000001")).toBe(9); + }); + + test("should read only the ten base digits", () => { + expect(calculatePisCheckDigit("12345678900")).toBe(0); + expect(calculatePisCheckDigit("12345678909")).toBe(0); + }); +}); diff --git a/src/_internals/calculate-pis-check-digit/calculate-pis-check-digit.ts b/src/_internals/calculate-pis-check-digit/calculate-pis-check-digit.ts new file mode 100644 index 000000000..42935f4d9 --- /dev/null +++ b/src/_internals/calculate-pis-check-digit/calculate-pis-check-digit.ts @@ -0,0 +1,32 @@ +import { PIS_WEIGHTS } from "../constants/pis"; + +const MODULUS = 11; + +/** + * Calculates the check digit of a PIS (Programa de Integração Social) base, the eleventh digit of + * the number. + * + * The ten base digits are multiplied by the weights 3, 2, 9, 8, 7, 6, 5, 4, 3 and 2, from left to + * right. The check digit is 11 minus the remainder of the weighted sum by 11, or 0 when that + * difference is 10 or 11. + * + * @param {string} base - The ten digits that precede the check digit. + * @returns {number} The check digit, 0 to 9. + * + * @example + * ```typescript + * calculatePisCheckDigit("1234567890"); // 0 + * calculatePisCheckDigit("1000000000"); // 8 + * ``` + */ +export const calculatePisCheckDigit = (base: string): number => { + let sum = 0; + + for (let index = 0; index < PIS_WEIGHTS.length; index++) { + sum += (base.charCodeAt(index) - 48) * PIS_WEIGHTS[index]; + } + + const digit = MODULUS - (sum % MODULUS); + + return digit >= 10 ? 0 : digit; +}; diff --git a/src/_internals/calculate-processo-juridico-check-digits/calculate-processo-juridico-check-digits.test.ts b/src/_internals/calculate-processo-juridico-check-digits/calculate-processo-juridico-check-digits.test.ts new file mode 100644 index 000000000..f428acc35 --- /dev/null +++ b/src/_internals/calculate-processo-juridico-check-digits/calculate-processo-juridico-check-digits.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "../test/runtime"; +import { calculateProcessoJuridicoCheckDigits } from "./calculate-processo-juridico-check-digits"; + +describe("calculateProcessoJuridicoCheckDigits", () => { + test("should return 29 for 0000001-29.2022.0.10.0001", () => { + expect(calculateProcessoJuridicoCheckDigits("000000120220100001")).toBe(29); + }); + + test("should return 98 when the number is a multiple of 97", () => { + expect(calculateProcessoJuridicoCheckDigits("000000000000000000")).toBe(98); + }); + + test("should return 95 for the number 1, whose product by 100 leaves a remainder of 3", () => { + expect(calculateProcessoJuridicoCheckDigits("000000000000000001")).toBe(95); + }); + + test("should carry the remainder of the first 11 digits into the last 7", () => { + expect(calculateProcessoJuridicoCheckDigits("999999999999999999")).toBe(28); + expect(calculateProcessoJuridicoCheckDigits("123456720138260001")).toBe(5); + }); + + test("should agree with the BigInt form of the check for the number 100000000000000000", () => { + const base = "100000000000000000"; + + expect(calculateProcessoJuridicoCheckDigits(base)).toBe( + Number(98n - ((BigInt(base) * 100n) % 97n)), + ); + }); +}); diff --git a/src/_internals/calculate-processo-juridico-check-digits/calculate-processo-juridico-check-digits.ts b/src/_internals/calculate-processo-juridico-check-digits/calculate-processo-juridico-check-digits.ts new file mode 100644 index 000000000..1ddad3644 --- /dev/null +++ b/src/_internals/calculate-processo-juridico-check-digits/calculate-processo-juridico-check-digits.ts @@ -0,0 +1,32 @@ +import { MOD_97_10_QUOTIENT, MOD_97_10_SUM } from "../constants/processo-juridico"; + +/** Digits of the head of the base, the largest prefix whose product by 100 still fits a double. */ +const HEAD_LENGTH = 11; + +/** + * Calculates the two verifying digits (`DD`) of a processo jurídico number, the ISO 7064 MOD + * 97-10 check of Resolução CNJ nº 65/2008, art. 1º, § 2º: 98 minus the remainder of the other 18 + * digits, read as one number and multiplied by 100, by 97. + * + * That 20 digit product does not fit a double, so the 18 digits are reduced in two steps: the + * remainder of the first 11 is carried into the last 7 (times 10 to the 9th, the 7 digits times + * the 100), which is the same remainder without ever leaving the safe integer range. + * + * @param {string} base - The 18 digits of the number without its verifying digits (`NNNNNNNAAAAJTROOOO`). + * @returns {number} The verifying digits as one number, 1 to 98. + * + * @example + * ```typescript + * calculateProcessoJuridicoCheckDigits("000000120220100001"); // 29 + * ``` + * + * @see Official: https://atos.cnj.jus.br/atos/detalhar/119 + */ +export const calculateProcessoJuridicoCheckDigits = (base: string): number => { + const head = Number(base.slice(0, HEAD_LENGTH)); + const tail = Number(base.slice(HEAD_LENGTH)); + + const remainder = ((head % MOD_97_10_QUOTIENT) * 1_000_000_000 + tail * 100) % MOD_97_10_QUOTIENT; + + return MOD_97_10_SUM - remainder; +}; 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 000000000..cd3a82b69 --- /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 000000000..e4e2f7471 --- /dev/null +++ b/src/_internals/calculate-renavam-check-digit/calculate-renavam-check-digit.ts @@ -0,0 +1,54 @@ +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 digit is the very same one `mod11`'s `arrecadacao` mapping yields, for every possible base, + * but the loop is written out here rather than delegated to it: pulling the shared, table driven + * `mod11` into this module costs `isValidRenavam` and `generateRenavam` around 100 B of bundle + * each, which the tree-shaking budget of this package does not spend on eight lines. + * + * 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/calculate-voter-id-first-digit/calculate-voter-id-first-digit.ts b/src/_internals/calculate-voter-id-first-digit/calculate-voter-id-first-digit.ts index b2bbad736..2c37e3f6d 100644 --- a/src/_internals/calculate-voter-id-first-digit/calculate-voter-id-first-digit.ts +++ b/src/_internals/calculate-voter-id-first-digit/calculate-voter-id-first-digit.ts @@ -31,6 +31,8 @@ export const calculateVoterIdFirstDigit = ({ sequentialNumber, federativeUnion, }: CalculateVoterIdFirstDigitParams): number => { + // The weighted sum is written out rather than delegated to the shared `generateChecksum`: its + // sanitizer chain costs `isValidVoterId` and `generateVoterId` around 285 B of bundle each. let sum = 0; for (let i = 0; i < SEQUENTIAL_LENGTH; i++) { diff --git a/src/_internals/constants/area-codes.ts b/src/_internals/constants/area-codes.ts index e2e4b34b9..975025a88 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 d11fbce18..41aac0ebc 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 f821a69e7..544d64066 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 7184c7fe4..ee47d5fbb 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 3ae058ddc..377b36acc 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 6d9b9e798..8756653e3 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 b1b833508..be4ea4b3d 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 000000000..6bcabc2cb --- /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 a past revision of the table retired, which `LEGAL_NATURE` keeps, 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 d9b06eb8d..f0bc29bc3 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 4d6b5b158..24db5086a 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", @@ -77,7 +85,7 @@ export const HUNDREDS_FEMININE: readonly string[] = [ "novecentas", ]; -export type NumberScaleWord = { +type NumberScaleWord = { /** Word used for a group whose value is exactly 1 (e.g. `"mil"`, `"milhão"`). */ singular: string; /** Word used for a group whose value is 0 or 2-999 (e.g. `"mil"`, `"milhões"`). */ diff --git a/src/_internals/constants/processo-juridico.ts b/src/_internals/constants/processo-juridico.ts index e96ff847d..09310b2c2 100644 --- a/src/_internals/constants/processo-juridico.ts +++ b/src/_internals/constants/processo-juridico.ts @@ -1,2 +1,73 @@ +/** + * 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; + +/** Modulus of the ISO 7064 MOD 97-10 check the two verifying digits (`DD`) come from. */ +export const MOD_97_10_QUOTIENT = 97; + +/** The MOD 97-10 check digits are this value minus the remainder of the number times 100 by 97. */ +export const MOD_97_10_SUM = 98; + +/** + * @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/separators.ts b/src/_internals/constants/separators.ts new file mode 100644 index 000000000..9c38108f3 --- /dev/null +++ b/src/_internals/constants/separators.ts @@ -0,0 +1,2 @@ +/** Mask characters (whitespace, dot, hyphen) tolerated in a formatted identifier. */ +export const SEPARATORS_REGEX = /[\s.-]/g; diff --git a/src/_internals/constants/service-phone.ts b/src/_internals/constants/service-phone.ts index bf2a7eeb3..7047ce8ec 100644 --- a/src/_internals/constants/service-phone.ts +++ b/src/_internals/constants/service-phone.ts @@ -16,43 +16,58 @@ * 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 = [ +export const SERVICE_PHONE_NON_GEOGRAPHIC_PREFIXES: readonly string[] = [ "0300", "0303", "0500", "0800", "0900", -] as const; +]; export const SERVICE_PHONE_NON_GEOGRAPHIC_PREFIX_LENGTH = 4; export const SERVICE_PHONE_NON_GEOGRAPHIC_LENGTH = 11; -export const SERVICE_PHONE_ABBREVIATED_ROOTS = ["300", "400"] as const; +export const SERVICE_PHONE_ABBREVIATED_ROOTS: readonly string[] = ["300", "400"]; export const SERVICE_PHONE_ABBREVIATED_ROOT_LENGTH = 3; export const SERVICE_PHONE_ABBREVIATED_LENGTH = 8; -export const SERVICE_PHONE_UTILITY_LENGTH = 3; - -export const SERVICE_PHONE_UTILITY_CODES = [ +export const SERVICE_PHONE_UTILITY_CODES: readonly string[] = [ "100", "102", "103", @@ -60,7 +75,6 @@ export const SERVICE_PHONE_UTILITY_CODES = [ "105", "106", "111", - "112", "115", "116", "117", @@ -78,6 +92,7 @@ export const SERVICE_PHONE_UTILITY_CODES = [ "135", "136", "138", + "141", "142", "145", "146", @@ -117,5 +132,4 @@ export const SERVICE_PHONE_UTILITY_CODES = [ "197", "198", "199", - "911", -] as const; +]; diff --git a/src/_internals/constants/state-codes.ts b/src/_internals/constants/state-codes.ts new file mode 100644 index 000000000..da2f2d39a --- /dev/null +++ b/src/_internals/constants/state-codes.ts @@ -0,0 +1,40 @@ +import { type StateCode } from "./states"; + +/** + * The two letter code of each Brazilian state published by the IBGE, in the order of `DATA` in + * `./states` (sorted by state name in the "pt-BR" locale), on its own so that a util which only + * needs the codes does not carry the whole states table into a consumer's bundle. + * + * Generated by `node ./scripts/states.ts`. Do not edit by hand. + * + * @see Official: https://servicodados.ibge.gov.br/api/docs/localidades + */ +export const STATE_CODES: readonly 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", +]; diff --git a/src/_internals/fetch-sorted-record/fetch-sorted-record.test.ts b/src/_internals/fetch-sorted-record/fetch-sorted-record.test.ts deleted file mode 100644 index b596ca2ab..000000000 --- a/src/_internals/fetch-sorted-record/fetch-sorted-record.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "../test/runtime"; -import { fetchSortedRecord } from "./fetch-sorted-record"; - -describe("fetchSortedRecord", () => { - const originalFetch = globalThis.fetch; - - beforeEach(() => { - vi.restoreAllMocks(); - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - }); - - it("should return the parsed entries sorted by key", async () => { - globalThis.fetch = vi.fn().mockResolvedValue(new Response("ignored", { status: 200 })); - - const sorted = await fetchSortedRecord("https://example.com/table", "Table", () => - Promise.resolve({ - "5102": "Venda", - "1102": "Compra", - "3102": "Compra do exterior", - }), - ); - - expect(Object.keys(sorted)).toEqual(["1102", "3102", "5102"]); - expect(sorted).toEqual({ - "1102": "Compra", - "3102": "Compra do exterior", - "5102": "Venda", - }); - }); - - it("should sort non-numeric keys alphabetically", async () => { - globalThis.fetch = vi.fn().mockResolvedValue(new Response("ignored", { status: 200 })); - - const sorted = await fetchSortedRecord("https://example.com/table", "Table", () => - Promise.resolve({ - banana: "2", - apple: "1", - cherry: "3", - }), - ); - - expect(Object.keys(sorted)).toEqual(["apple", "banana", "cherry"]); - }); - - it("should hand the response to the parser", async () => { - globalThis.fetch = vi.fn().mockResolvedValue(new Response("a;1\nb;2", { status: 200 })); - - const sorted = await fetchSortedRecord( - "https://example.com/table", - "Table", - async (response) => { - const entries: Record = {}; - - const body = await response.text(); - - for (const line of body.split("\n")) { - const [key, value] = line.split(";"); - - expect(key).toBeDefined(); - expect(value).toBeDefined(); - - if (key === undefined || value === undefined) { - continue; - } - - entries[key] = value; - } - - return entries; - }, - ); - - expect(sorted).toEqual({ a: "1", b: "2" }); - }); - - it("should reject with the label and status when the response is not ok", async () => { - globalThis.fetch = vi.fn().mockResolvedValue(new Response("", { status: 503 })); - - await expect( - fetchSortedRecord("https://example.com/table", "CFOP mirror", () => Promise.resolve({})), - ).rejects.toThrow("CFOP mirror request failed with status 503"); - }); -}); diff --git a/src/_internals/fetch-with-retry/fetch-with-retry.test.ts b/src/_internals/fetch-with-retry/fetch-with-retry.test.ts index bab386345..44de5f97d 100644 --- a/src/_internals/fetch-with-retry/fetch-with-retry.test.ts +++ b/src/_internals/fetch-with-retry/fetch-with-retry.test.ts @@ -99,18 +99,25 @@ describe("fetchWithRetry", () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); - it("throws without ever attempting the fetch when retries is negative", async () => { + it("throws without ever attempting the fetch when retries is not an integer of zero or greater", async () => { const fetchMock = vi.fn(); globalThis.fetch = fetchMock; - const rejection = await fetchWithRetry("https://example.com", { retries: -1 }).then( - () => { - throw new Error("expected the fetch to reject"); - }, - (error: unknown) => error, - ); + for (const retries of [-1, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) { + // eslint-disable-next-line no-await-in-loop + const rejection = await fetchWithRetry("https://example.com", { retries }).then( + () => { + throw new Error("expected the fetch to reject"); + }, + (error: unknown) => error, + ); + + expect(rejection).toBeInstanceOf(RangeError); + expect((rejection as RangeError).message).toBe( + "retries must be an integer of zero or greater", + ); + } - expect(rejection).toBeUndefined(); expect(fetchMock).toHaveBeenCalledTimes(0); }); @@ -127,16 +134,12 @@ describe("fetchWithRetry", () => { "ETIMEDOUT", ]; - const expectEachRetries = async ([code, ...rest]: string[]): Promise => { - if (code === undefined) return; - + for (const code of RETRYABLE_CODES) { const error = Object.assign(new Error("boom"), { code }); + // eslint-disable-next-line no-await-in-loop await expectRetrySucceeds(mockFetchRejectingOnceWith(error)); - await expectEachRetries(rest); - }; - - await expectEachRetries(RETRYABLE_CODES); + } }); it("does not retry when the error code is unknown", async () => { @@ -205,11 +208,11 @@ describe("fetchWithRetry", () => { const start = Date.now(); await expect( - fetchWithRetry("https://example.com", { retries: 0, retryDelayMs: 200 }), + fetchWithRetry("https://example.com", { retries: 0, retryDelayMs: 5000 }), ).rejects.toThrow(error); const elapsed = Date.now() - start; - expect(elapsed).toBeLessThan(100); + expect(elapsed).toBeLessThan(2500); }); it("increases the wait delay linearly with each retry attempt", async () => { diff --git a/src/_internals/fetch-with-retry/fetch-with-retry.ts b/src/_internals/fetch-with-retry/fetch-with-retry.ts index bbca2f474..e9b6473f4 100644 --- a/src/_internals/fetch-with-retry/fetch-with-retry.ts +++ b/src/_internals/fetch-with-retry/fetch-with-retry.ts @@ -64,37 +64,44 @@ const wait = (ms: number): Promise => setTimeout(resolve, ms); }); -type Attempt = { - retries: number; - retryDelayMs: number; - attempt: number; - lastError?: unknown; -}; - +/** + * Performs the attempts of `fetchWithRetry` in a loop: the first attempt plus one retry per + * `retries`, waiting `retryDelayMs * attempt` before each retry. Written as a loop rather than a + * recursive attempt so a long retry budget never grows the call stack. A negative `retries` + * rejects before any attempt, as it always did. + * + * @param {string|URL|Request} input - The resource to fetch. + * @param {RequestInit} init - The `fetch` init. + * @param {number} retries - How many retries follow the first attempt. + * @param {number} retryDelayMs - The base delay, multiplied by the attempt number. + * @returns {Promise} The first successful `fetch` response. + */ const attemptFetch = async ( input: string | URL | Request, init: RequestInit, - { retries, retryDelayMs, attempt, lastError }: Attempt, + retries: number, + retryDelayMs: number, ): Promise => { - if (attempt > retries) { - throw lastError; + if (!Number.isInteger(retries) || retries < 0) { + throw new RangeError("retries must be an integer of zero or greater"); } - try { - return await fetch(input, init); - } catch (error) { - if (attempt === retries || !isRetryableFetchError(error)) { - throw error; - } + let attempt = 0; - await wait(retryDelayMs * (attempt + 1)); + for (;;) { + try { + // eslint-disable-next-line no-await-in-loop + return await fetch(input, init); + } catch (error) { + if (attempt >= retries || !isRetryableFetchError(error)) { + throw error; + } + } - return attemptFetch(input, init, { - retries, - retryDelayMs, - attempt: attempt + 1, - lastError: error, - }); + attempt++; + // Retries are sequential by definition: each one waits for the previous failure and its backoff. + // eslint-disable-next-line no-await-in-loop + await wait(retryDelayMs * attempt); } }; @@ -117,4 +124,4 @@ const attemptFetch = async ( export const fetchWithRetry = ( input: string | URL | Request, { retries = 2, retryDelayMs = 250, ...init }: FetchWithRetryOptions = {}, -): Promise => attemptFetch(input, init, { retries, retryDelayMs, attempt: 0 }); +): Promise => attemptFetch(input, init, retries, retryDelayMs); diff --git a/src/_internals/generate-checksum/generate-checksum.ts b/src/_internals/generate-checksum/generate-checksum.ts index 641eb788e..273c6d109 100644 --- a/src/_internals/generate-checksum/generate-checksum.ts +++ b/src/_internals/generate-checksum/generate-checksum.ts @@ -4,7 +4,7 @@ export type GenerateChecksumParams = { /** The digits the checksum is computed over. */ base: string | number; /** A starting weight that decreases along the digits, or the explicit weight of each digit. */ - weight: number | number[]; + weight: number | readonly number[]; }; /** diff --git a/src/_internals/is-legacy-legal-nature/is-legacy-legal-nature.test.ts b/src/_internals/is-legacy-legal-nature/is-legacy-legal-nature.test.ts new file mode 100644 index 000000000..c55c2bd43 --- /dev/null +++ b/src/_internals/is-legacy-legal-nature/is-legacy-legal-nature.test.ts @@ -0,0 +1,27 @@ +import { LEGACY_LEGAL_NATURE, LEGAL_NATURE } from "../../is-valid-legal-nature/constants"; +import { describe, expect, test } from "../test/runtime"; +import { isLegacyLegalNature } from "./is-legacy-legal-nature"; + +describe("isLegacyLegalNature", () => { + test("should return true for every retired code", () => { + for (const code of Object.keys(LEGACY_LEGAL_NATURE)) { + expect(isLegacyLegalNature(code)).toBe(true); + } + }); + + test("should return false for a code in force", () => { + expect(isLegacyLegalNature("2062")).toBe(false); + expect(isLegacyLegalNature("1015")).toBe(false); + }); + + test("should return false for an unknown code and for an inherited property name", () => { + expect(isLegacyLegalNature("0000")).toBe(false); + expect(isLegacyLegalNature("toString")).toBe(false); + }); + + test("should retire a strict subset of the table", () => { + const legacy = Object.keys(LEGAL_NATURE).filter((code) => isLegacyLegalNature(code)); + + expect(legacy).toStrictEqual(Object.keys(LEGACY_LEGAL_NATURE)); + }); +}); diff --git a/src/_internals/is-legacy-legal-nature/is-legacy-legal-nature.ts b/src/_internals/is-legacy-legal-nature/is-legacy-legal-nature.ts new file mode 100644 index 000000000..bedf7cc7e --- /dev/null +++ b/src/_internals/is-legacy-legal-nature/is-legacy-legal-nature.ts @@ -0,0 +1,18 @@ +import { LEGACY_LEGAL_NATURE } from "../../is-valid-legal-nature/constants"; + +/** + * Checks whether a legal nature (natureza jurídica) code is one a past revision of the CONCLA + * table retired: still accepted by `isValidLegalNature`, but left out of the listings and never + * generated. + * + * @param {string} code - The 4 digit legal nature code. + * @returns {boolean} True when the code is a retired one. + * + * @example + * ```typescript + * isLegacyLegalNature("2076"); // true + * isLegacyLegalNature("2062"); // false + * ``` + */ +export const isLegacyLegalNature = (code: string): boolean => + Object.hasOwn(LEGACY_LEGAL_NATURE, code); diff --git a/src/_internals/is-state-code/is-state-code.test.ts b/src/_internals/is-state-code/is-state-code.test.ts new file mode 100644 index 000000000..705819a7b --- /dev/null +++ b/src/_internals/is-state-code/is-state-code.test.ts @@ -0,0 +1,28 @@ +import { STATE_CODES } from "../constants/state-codes"; +import { DATA } from "../constants/states"; +import { describe, expect, test } from "../test/runtime"; +import { isStateCode } from "./is-state-code"; + +describe("isStateCode", () => { + test("should return true for every state code of the states table", () => { + for (const { code } of DATA) { + expect(isStateCode(code)).toBe(true); + } + }); + + test("should list exactly the codes of the states table, in the same order", () => { + expect(STATE_CODES).toStrictEqual(DATA.map((state) => state.code)); + }); + + test("should return false for a lower case, padded or unknown code", () => { + expect(isStateCode("sp")).toBe(false); + expect(isStateCode(" SP")).toBe(false); + expect(isStateCode("XX")).toBe(false); + expect(isStateCode("")).toBe(false); + }); + + test("should return false for a key of the prototype chain", () => { + expect(isStateCode("constructor")).toBe(false); + expect(isStateCode("__proto__")).toBe(false); + }); +}); diff --git a/src/_internals/is-state-code/is-state-code.ts b/src/_internals/is-state-code/is-state-code.ts new file mode 100644 index 000000000..7834e60c0 --- /dev/null +++ b/src/_internals/is-state-code/is-state-code.ts @@ -0,0 +1,21 @@ +import { STATE_CODES } from "../constants/state-codes"; +import { type StateCode } from "../constants/states"; + +/** + * Checks whether a value is the two letter code of a Brazilian state, as published by the IBGE. + * + * The check is exact: the value has to be already trimmed and in upper case, and a key of the + * prototype chain (`"constructor"`) is no state code like any other unknown value. + * + * @param {string} value - The value to check. + * @returns {boolean} True when the value is one of the 27 state codes. + * + * @example + * ```typescript + * isStateCode("SP"); // true + * isStateCode("sp"); // false + * isStateCode("XX"); // false + * ``` + */ +export const isStateCode = (value: string): value is StateCode => + STATE_CODES.some((code) => code === value); 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 000000000..35ae5b4bd --- /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 000000000..d157d2bf9 --- /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 540718309..e08c44580 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/is-valid-date/is-valid-date.test.ts b/src/_internals/is-valid-date/is-valid-date.test.ts new file mode 100644 index 000000000..bf28fdb69 --- /dev/null +++ b/src/_internals/is-valid-date/is-valid-date.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, expectTypeOf, test } from "../test/runtime"; +import { isValidDate } from "./is-valid-date"; + +describe("isValidDate", () => { + test("should accept a date naming a real instant", () => { + expect(isValidDate(new Date(2024, 0, 1))).toBe(true); + }); + + test("should reject a date whose time is NaN", () => { + expect(isValidDate(new Date("nonsense"))).toBe(false); + }); + + test("should reject a value that is not a date", () => { + expect(isValidDate("2024-01-01")).toBe(false); + expect(isValidDate(1_704_067_200_000)).toBe(false); + expect(isValidDate(null)).toBe(false); + expect(isValidDate({ getTime: () => 0 })).toBe(false); + }); + + test("types", () => { + expectTypeOf(isValidDate).parameter(0).toEqualTypeOf(); + expectTypeOf(isValidDate).returns.toEqualTypeOf(); + }); +}); diff --git a/src/_internals/is-valid-date/is-valid-date.ts b/src/_internals/is-valid-date/is-valid-date.ts new file mode 100644 index 000000000..a3c99cd72 --- /dev/null +++ b/src/_internals/is-valid-date/is-valid-date.ts @@ -0,0 +1,20 @@ +/** + * Checks whether a value is a `Date` that names a real instant. + * + * `new Date("nonsense")` is still a `Date`, only one whose time is `NaN`, so every date utility + * of this library checks both before it reads a value handed to it, and returns the empty value + * of its family (`null` or `false`) instead of computing with `NaN`. + * + * @param {unknown} value - The value to check. + * @returns {boolean} True when the value is a `Date` whose time is not `NaN`. + * + * @example + * ```typescript + * isValidDate(new Date(2024, 0, 1)); // true + * isValidDate(new Date("nonsense")); // false + * isValidDate("2024-01-01"); // false + * isValidDate(null); // false + * ``` + */ +export const isValidDate = (value: unknown): value is Date => + value instanceof Date && !Number.isNaN(value.getTime()); 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 000000000..7906066f1 --- /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 000000000..b46b101fe --- /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 1337bdf4b..4fe34a81a 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 94935cb34..c92bb5313 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 000000000..2f2eda578 --- /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 000000000..0768a5f02 --- /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/pick-random/pick-random.test.ts b/src/_internals/pick-random/pick-random.test.ts new file mode 100644 index 000000000..dcf857ade --- /dev/null +++ b/src/_internals/pick-random/pick-random.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, expectTypeOf, test } from "../test/runtime"; +import { pickRandom } from "./pick-random"; + +describe("pickRandom", () => { + test("should always pick the only item of a one item list", () => { + expect(pickRandom(["only"])).toBe("only"); + }); + + test("should only ever pick an item of the list", () => { + const items = [11, 21, 31, 41]; + + for (let i = 0; i < 200; i++) { + expect(items).toContain(pickRandom(items)); + } + }); + + test("should reach every item of the list", () => { + const items = ["a", "b", "c"]; + const seen = new Set(); + + for (let i = 0; i < 500; i++) { + seen.add(pickRandom(items)); + } + + expect([...seen].sort()).toEqual(items); + }); + + test("types", () => { + expectTypeOf(pickRandom).returns.toEqualTypeOf(); + }); +}); diff --git a/src/_internals/pick-random/pick-random.ts b/src/_internals/pick-random/pick-random.ts new file mode 100644 index 000000000..bba02e98f --- /dev/null +++ b/src/_internals/pick-random/pick-random.ts @@ -0,0 +1,17 @@ +/** + * Picks one item of a list at random, with every item equally likely. + * + * Uses `Math.random()`, so it is not cryptographically secure; it only ever serves the + * `generate*` utilities, which say so in their own documentation. + * + * @param {readonly Item[]} items - The list to pick from. + * @returns {Item} One item of the list. + * + * @example + * ```typescript + * pickRandom(["mobile", "landline"]); // "landline" + * pickRandom([11, 21, 31]); // 21 + * ``` + */ +export const pickRandom = (items: readonly Item[]): Item => + items[Math.floor(Math.random() * items.length)]; 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 37e3e8c70..3a3bde3d4 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,12 +29,25 @@ describe("resolveStateHolidayDate", () => { ); }); - 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`"; + 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(2024, {})).toThrow(message); - expect(() => resolveStateHolidayDate(2024, { day: 10 })).toThrow(message); - expect(() => resolveStateHolidayDate(2024, { month: 5 })).toThrow(message); + 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)); }); }); 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 c1398b3a6..460c0397b 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 @@ -1,13 +1,27 @@ /** How a holiday's date is defined: a fixed day and month, or an offset in days from Easter Sunday. */ -export type HolidayDateRule = { - /** Day of the month, 1 to 31, used together with `month`. */ - day?: number; - /** Month, 1 to 12, used together with `day`. */ - month?: number; - /** Offset in days from Easter Sunday (Carnaval is -47, Corpus Christi is 60); Easter itself is 0. */ - easterOffset?: number; +export type HolidayDateRule = ( + | { + /** Offset in days from Easter Sunday (Carnaval is -47, Corpus Christi is 60); Easter itself is 0. */ + easterOffset: number; + } + | { + /** Day of the month, 1 to 31, used together with `month`. */ + day: number; + /** Month, 1 to 12, used together with `day`. */ + month: number; + } +) & { + /** + * Whether the holiday is observed on the following Sunday when the date the rule resolves 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 +49,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 +75,16 @@ 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, -): Date => { - if (easterOffset !== undefined) { - return calculateHolidayFromEaster(year, easterOffset); - } - - if (day !== undefined && month !== undefined) { - return new Date(year, month - 1, day); - } +export const resolveStateHolidayDate = (year: number, rule: HolidayDateRule): Date => { + const date = + "easterOffset" in rule + ? calculateHolidayFromEaster(year, rule.easterOffset) + : new Date(year, rule.month - 1, rule.day); - throw new Error( - "State holiday entry must define either `easterOffset` or both `day` and `month`", - ); + return rule.nextSundayWhenWeekday === true ? moveToNextSundayWhenWeekday(date) : date; }; diff --git a/src/_internals/sanitize-cnpj/sanitize-cnpj.test.ts b/src/_internals/sanitize-cnpj/sanitize-cnpj.test.ts new file mode 100644 index 000000000..dc92dcea6 --- /dev/null +++ b/src/_internals/sanitize-cnpj/sanitize-cnpj.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, expectTypeOf, test } from "../test/runtime"; +import { sanitizeCnpj } from "./sanitize-cnpj"; + +describe("sanitizeCnpj", () => { + test("should keep only the digits when no version is given", () => { + expect(sanitizeCnpj("11.222.333/0001-81")).toBe("11222333000181"); + }); + + test("should keep only the digits on the numeric version", () => { + expect(sanitizeCnpj("12.ABC.345/01DE-35", 1)).toBe("123450135"); + }); + + test("should keep the upper cased letters on the alphanumeric version", () => { + expect(sanitizeCnpj("12.abc.345/01de-35", 2)).toBe("12ABC34501DE35"); + }); + + test("should read a number as its digits", () => { + expect(sanitizeCnpj(11_222_333_000_181)).toBe("11222333000181"); + }); + + test("types", () => { + expectTypeOf(sanitizeCnpj).parameter(0).toEqualTypeOf(); + expectTypeOf(sanitizeCnpj).returns.toEqualTypeOf(); + }); +}); diff --git a/src/_internals/sanitize-cnpj/sanitize-cnpj.ts b/src/_internals/sanitize-cnpj/sanitize-cnpj.ts new file mode 100644 index 000000000..83f40f642 --- /dev/null +++ b/src/_internals/sanitize-cnpj/sanitize-cnpj.ts @@ -0,0 +1,28 @@ +import { sanitizeToAlphanumeric } from "../sanitize-to-alphanumeric/sanitize-to-alphanumeric"; +import { sanitizeToDigits } from "../sanitize-to-digits/sanitize-to-digits"; + +/** + * Sanitizes a CNPJ value to the characters its version is written with: the digits of the + * numeric CNPJ (version `1`, the default), or the upper cased letters and digits of the + * alphanumeric one (version `2`). + * + * Shared by `formatCnpj` and `parseCnpj`, which read a value the very same way. + * + * @param {string|number} value - The CNPJ value to sanitize. + * @param {1|2} [version] - The CNPJ version to read the value as. Defaults to the numeric one. + * @returns {string} The sanitized value. + * + * @example + * ```typescript + * sanitizeCnpj("11.222.333/0001-81"); // "11222333000181" + * sanitizeCnpj("12.ABC.345/01DE-35", 2); // "12ABC34501DE35" + * sanitizeCnpj("12.ABC.345/01DE-35"); // "123450135", the digits only + * ``` + */ +export const sanitizeCnpj = (value: string | number, version?: 1 | 2): string => { + if (version === 2) { + return sanitizeToAlphanumeric(value); + } + + return sanitizeToDigits(value); +}; diff --git a/src/_internals/sanitize-to-alphanumeric/sanitize-to-alphanumeric.ts b/src/_internals/sanitize-to-alphanumeric/sanitize-to-alphanumeric.ts index ccf97d62f..af016eb62 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-ascii/sanitize-to-ascii.ts b/src/_internals/sanitize-to-ascii/sanitize-to-ascii.ts index ec5fea359..8c415cbfa 100644 --- a/src/_internals/sanitize-to-ascii/sanitize-to-ascii.ts +++ b/src/_internals/sanitize-to-ascii/sanitize-to-ascii.ts @@ -1,5 +1,3 @@ -const COMBINING_MARKS_REGEX = /[\u0300-\u036F]/g; - const WHITESPACE_REGEX = /\s/g; const NON_PRINTABLE_ASCII_REGEX = /[^\u0020-\u007E]/g; @@ -27,7 +25,6 @@ const SPACE_RUN_REGEX = / {2,}/g; export const sanitizeToAscii = (value: string): string => value .normalize("NFD") - .replace(COMBINING_MARKS_REGEX, "") .replace(WHITESPACE_REGEX, " ") .replace(NON_PRINTABLE_ASCII_REGEX, "") .replace(SPACE_RUN_REGEX, " ") diff --git a/src/_internals/sanitize-to-digits/sanitize-to-digits.ts b/src/_internals/sanitize-to-digits/sanitize-to-digits.ts index 4bb380c2b..57f854fab 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/strip-phone-country-code/strip-phone-country-code.ts b/src/_internals/strip-phone-country-code/strip-phone-country-code.ts index 51670dc24..8502e57ad 100644 --- a/src/_internals/strip-phone-country-code/strip-phone-country-code.ts +++ b/src/_internals/strip-phone-country-code/strip-phone-country-code.ts @@ -1,4 +1,5 @@ import { sanitizeToDigits } from "../sanitize-to-digits/sanitize-to-digits"; +import { toStringSafe } from "../to-string-safe/to-string-safe"; const EXPLICIT_COUNTRY_CODE_REGEX = /^\s*(?:\+|00)\s*55/; @@ -18,10 +19,9 @@ const EXPLICIT_COUNTRY_CODE_REGEX = /^\s*(?:\+|00)\s*55/; * ``` */ export const stripPhoneCountryCode = (value: string | number): string => { - // Stryker disable next-line ConditionalExpression: a number cannot carry a "+" or "00" prefix, so running it through the regex changes nothing. - if (typeof value !== "string") return sanitizeToDigits(value); + const text = toStringSafe(value); - const match = EXPLICIT_COUNTRY_CODE_REGEX.exec(value); + const match = EXPLICIT_COUNTRY_CODE_REGEX.exec(text); - return sanitizeToDigits(match ? value.slice(match[0].length) : value); + return sanitizeToDigits(match ? text.slice(match[0].length) : text); }; diff --git a/src/_internals/test/arbitraries.ts b/src/_internals/test/arbitraries.ts index a606b5eb7..4bb3dc57d 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/noop.ts b/src/_internals/test/noop.ts index fd6a27862..dd0124907 100644 --- a/src/_internals/test/noop.ts +++ b/src/_internals/test/noop.ts @@ -5,8 +5,8 @@ const chain: unknown = new Proxy(() => chain, { apply: () => chain, get: () => c /** * Runtime stand-in for vitest's `expectTypeOf` on Bun and Deno: every call and property access * returns the same chainable no-op, so a `describe(" types")` block runs without effect - * there. The assertions themselves are checked statically by `vp check` and by - * `npm run test:types`. + * there. The assertions themselves are checked statically by `vp check`, which type-checks the + * test files too. */ export const expectTypeOf = chain as typeof vitestExpectTypeOf; diff --git a/src/_internals/test/properties.ts b/src/_internals/test/properties.ts index 08e38313c..e40050024 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 db72f70d3..33aedb0ef 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 { @@ -51,49 +64,106 @@ function describeValue(value: unknown): string { } } -function deepEqual(a: unknown, b: unknown): boolean { - if (Object.is(a, b)) { - return true; - } +type Pair = [unknown, unknown]; +type SeenPairs = WeakMap>; - if (a instanceof Date && b instanceof Date) { - return a.getTime() === b.getTime(); - } +function isObject(value: unknown): value is object { + return typeof value === "object" && value !== null; +} + +/** + * Records the pair and reports whether it had been recorded before, so a cycle (an object that + * references itself, or two objects that reference each other) is compared once instead of forever. + */ +function seenBefore(seen: SeenPairs, a: object, b: object): boolean { + const partners = seen.get(a) ?? new WeakSet(); + + if (partners.has(b)) return true; + + partners.add(b); + seen.set(a, partners); + + return false; +} + +/** + * Queues the element pairs of two arrays or the entry pairs of two records for comparison, or + * reports that the two values can only be equal when `Object.is` says so (dates compare by time). + */ +function queuePairs(a: unknown, b: unknown, pending: Pair[]): boolean { + if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime(); if (Array.isArray(a) && Array.isArray(b)) { - return a.length === b.length && a.every((value, index) => deepEqual(value, b[index])); + if (a.length !== b.length) return false; + for (let index = 0; index < a.length; index++) pending.push([a[index], b[index]]); + return true; } if (isRecord(a) && isRecord(b)) { const aKeys = Object.keys(a); const bKeys = Object.keys(b); - return ( - aKeys.length === bKeys.length && - aKeys.every((key) => bKeys.includes(key) && deepEqual(a[key], b[key])) - ); + if (aKeys.length !== bKeys.length) return false; + + for (const key of aKeys) { + if (!bKeys.includes(key)) return false; + pending.push([a[key], b[key]]); + } + return true; } return false; } +function deepEqual(left: unknown, right: unknown): boolean { + const pending: Pair[] = [[left, right]]; + const seen: SeenPairs = new WeakMap(); + + while (pending.length > 0) { + const pair = pending.pop(); + + if (pair === undefined) break; + + const [a, b] = pair; + + if (Object.is(a, b)) continue; + if (isObject(a) && isObject(b) && seenBefore(seen, a, b)) continue; + if (!queuePairs(a, b, pending)) return false; + } + + return true; +} + function objectMatches( actual: Record, expected: Record, ): boolean { - return Object.entries(expected).every(([key, value]) => { - if (!(key in actual)) { - return false; - } + const pending: [Record, Record][] = [[actual, expected]]; + const seen: SeenPairs = new WeakMap(); + + while (pending.length > 0) { + const pair = pending.pop(); + + if (pair === undefined) break; + + const [actualRecord, expectedRecord] = pair; - const actualValue = actual[key]; + if (seenBefore(seen, actualRecord, expectedRecord)) continue; - if (isRecord(value) && isRecord(actualValue)) { - return objectMatches(actualValue, value); + for (const [key, value] of Object.entries(expectedRecord)) { + if (!(key in actualRecord)) return false; + + const actualValue = actualRecord[key]; + + if (isRecord(value) && isRecord(actualValue)) { + pending.push([actualValue, value]); + } else if (!deepEqual(actualValue, value)) { + return false; + } } + } - return deepEqual(actualValue, value); - }); + return true; } function createMock(implementation?: MockImplementation): MockFunction { @@ -122,8 +192,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 +380,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 +397,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 +411,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 +428,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 +443,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 +452,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 +490,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}`); @@ -463,12 +541,10 @@ function createExpect(actual: unknown): ExpectResult { } async function runHooks(hooks: TestCallback[]): Promise { - const [hook, ...rest] = hooks; - - if (hook === undefined) return; - - await hook(); - await runHooks(rest); + for (const hook of hooks) { + // eslint-disable-next-line no-await-in-loop + await hook(); + } } function currentSuiteChain(): Suite[] { @@ -559,7 +635,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 000000000..9a85898a5 --- /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 000000000..ab9115928 --- /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 9bcbc3ae0..65513c766 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 a31d2be2a..8696a6c51 100644 --- a/src/add-business-days/add-business-days.ts +++ b/src/add-business-days/add-business-days.ts @@ -1,102 +1,92 @@ -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 { isValidDate } from "../_internals/is-valid-date/is-valid-date"; +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 (!isValidDate(date)) return null; - if (!(date instanceof Date) || Number.isNaN(date.getTime())) return null; + if (!Number.isInteger(amount)) return null; - if (!Number.isInteger(days)) 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 8aa6fc9db..fee7e412b 100644 --- a/src/capitalize/capitalize.test.ts +++ b/src/capitalize/capitalize.test.ts @@ -1,5 +1,6 @@ import * as fc from "fast-check"; +import { expectNeverThrowsWithOptions } from "../_internals/test/properties"; import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; import { capitalize, type CapitalizeOptions } from "./capitalize"; @@ -43,11 +44,139 @@ 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"); + expect(capitalize("me e você")).toBe("Me e Você"); + expect(capitalize("me")).toBe("ME"); + expect(capitalize("envie-me, cpf")).toBe("Envie-Me, CPF"); + expect(capitalize("envie-me ltda")).toBe("Envie-Me LTDA"); + expect(capitalize("dê-me a mão")).toBe("Dê-Me a Mão"); + expect(capitalize("por favor, diga-me")).toBe("Por Favor, Diga-Me"); + expect(capitalize("fulano, me")).toBe("Fulano, ME"); + expect(capitalize("fulano/me")).toBe("Fulano/ME"); + expect(capitalize("fulano d’me")).toBe("Fulano d’Me"); + expect(capitalize("fulano d‘me")).toBe("Fulano d‘Me"); + expect(capitalize("fulano me cpf")).toBe("Fulano Me CPF"); + expect(capitalize("fulano me, epp")).toBe("Fulano Me, EPP"); + expect(capitalize("fulano me s/a")).toBe("Fulano ME S/A"); + expect(capitalize("fulano me s.a.")).toBe("Fulano ME S.A."); + expect(capitalize("fulano me s/x")).toBe("Fulano Me S/X"); + expect(capitalize("fulano me s / a")).toBe("Fulano Me S / A"); + expect(capitalize("fulano me s a")).toBe("Fulano Me S A"); + expect(capitalize("fulano me epp ltda")).toBe("Fulano ME EPP LTDA"); + expect(capitalize("fulano me s/")).toBe("Fulano Me S/"); + expect(capitalize("fulano me epp", { upperCaseWords: ["ME"] })).toBe("Fulano Me Epp"); + expect(capitalize("fulano me s/a", { upperCaseWords: ["ME"] })).toBe("Fulano Me S/A"); + expect(capitalize("fulano me", { upperCaseWords: ["ME"] })).toBe("Fulano ME"); + }); + + 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 +185,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 +212,40 @@ 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"); + }); + }); + 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 70d238fb5..1d9f7a211 100644 --- a/src/capitalize/capitalize.ts +++ b/src/capitalize/capitalize.ts @@ -1,84 +1,297 @@ -import { PREPOSITIONS, SEPARATOR_REGEX, WHITESPACE_REGEX } from "./constants"; +import { STATE_CODES } from "../_internals/constants/state-codes"; +import { + APOSTROPHE_REGEX, + COMPANY_DESIGNATIONS, + ELIDED_PARTICLE, + ENCLISIS_REGEX, + JOINER_REGEX, + PREPOSITIONS, + PUNCTUATION_REGEX, + SEPARATOR_REGEX, + 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 companyDesignationSet: Set = new Set(COMPANY_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 index of the next word of the value after `index`, `tokens.length` when there is none. + * + * @param {string[]} tokens - Every token of the value, words and separators alike. + * @param {number} index - The index to look ahead from. + * @returns {number} The index of the next word. + */ +const nextWordIndex = (tokens: string[], index: number): number => { + const offset = tokens.slice(index + 1).findIndex((token) => isWord(token)); + + return offset === -1 ? tokens.length : index + 1 + offset; +}; + +/** + * What follows the word at `index`: whether the next word is joined to it, that is, whether only + * whitespace, `-`, `/` or an apostrophe stands between the two, and the designation the next + * word forms, which is the word itself (`"EPP"`) or the word and the one after it when a slash + * alone joins them (`"S/A"`); `""` when no word follows. + * + * @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; designation: string }} Whether the next word is joined to the word at `index` and the designation it forms. + */ +const lookAhead = (tokens: string[], index: number): { joined: boolean; designation: string } => { + const position = nextWordIndex(tokens, index); + + if (position === tokens.length) return { joined: false, designation: "" }; + + const joined = tokens + .slice(index + 1, position) + .every((token) => token === "" || JOINER_REGEX.test(token)); + const next = tokens[position]; + const following = nextWordIndex(tokens, position); + const acrossSlash = tokens.slice(position + 1, following + 1).join(""); + + return { joined, designation: acrossSlash.startsWith("/") ? next + acrossSlash : 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 an adjacent company + * designation of the list in force (`"EPP"`, `"S/A"`), and never when a hyphen or an apostrophe + * attaches them to the previous word, where they are the enclitic pronoun (`"diga-me"`). + * + * @param {string} word - The word being written, in upper case. + * @param {boolean} enclitic - Whether a hyphen or an apostrophe attaches the word to the previous one. + * @param {{ joined: boolean; designation: string }} ahead - What follows the word: whether the next word is joined to it and the designation it forms (`""` 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, + enclitic: boolean, + ahead: { joined: boolean; designation: string }, + upperCaseSet: Set, +): boolean => { + if (!trailingDesignationSet.has(word)) return true; + if (enclitic) return false; + if (ahead.designation === "") return true; + + const designation = ahead.designation.toLocaleUpperCase("pt-BR"); + + return ahead.joined && companyDesignationSet.has(designation) && upperCaseSet.has(designation); +}; + +/** + * 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; + let enclitic = false; - for (const token of tokens) { + for (const [index, token] of tokens.entries()) { if (!token) continue; if (WHITESPACE_REGEX.test(token)) { - result += " "; + output.push(" "); + enclitic = false; continue; } - if (token === "-" || token === "/") { - result += token; + if (PUNCTUATION_REGEX.test(token)) { + output.push(token); + enclitic = ENCLISIS_REGEX.test(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 ahead = lookAhead(tokens, index); - if (wordIndex > 0 && lowerCaseSet.has(lowerCaseWord)) { - result += lowerCaseWord; - } else if (upperCaseSet.has(upperCaseWord)) { - result += upperCaseWord; + if (designation !== upperCaseWord && 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 && ahead.joined && lowerCaseSet.has(lowerCaseWord)) { + output.push(lowerCaseWord); + } else if ( + upperCaseSet.has(upperCaseWord) && + isUpperCasePosition(upperCaseWord, enclitic, ahead, 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 081cd985b..32b00204c 100644 --- a/src/capitalize/constants.ts +++ b/src/capitalize/constants.ts @@ -1,3 +1,24 @@ +/** + * 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 +27,12 @@ export const PREPOSITIONS = [ "de", "do", "dos", + "del", + "della", + "den", + "der", + "di", + "du", "e", "em", "na", @@ -15,8 +42,140 @@ export const PREPOSITIONS = [ "o", "por", "sem", + "van", + "von", ]; -export const SEPARATOR_REGEX = /(\s+|[-/])/; +/** + * Company designations that are written in upper case in Brazilian names, and the only words a + * designation of `TRAILING_DESIGNATIONS` is upper case before. "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. + */ +export const COMPANY_DESIGNATIONS = [ + "CIA", + "EIRELI", + "EPP", + "LTDA", + "ME", + "MEI", + "S.A.", + "S.S.", + "S/A", + "S/S", + "SCP", +]; + +/** The document abbreviations that are written in upper case wherever they appear. */ +const DOCUMENT_ABBREVIATIONS = ["CEP", "CNPJ", "CPF", "RG", "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 = [ + ...COMPANY_DESIGNATIONS, + ...DOCUMENT_ABBREVIATIONS, + ...ROMAN_NUMERALS, +]; + +/** + * 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"; + +/** + * The separators that attach an enclitic pronoun to its verb (`"diga-me"`, `"d'me"`): a word of + * `TRAILING_DESIGNATIONS` written right after one of them is the pronoun, never the designation. + */ +export const ENCLISIS_REGEX = /^[-'’‘]$/; + +/** + * 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 company designation + * ("Fulano ME EPP"), and never attached to the previous word by a hyphen or an apostrophe. `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 cbd4ec874..91ad25629 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 c1d0a35e1..153ec7b29 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 63d6e816f..9b1f08f44 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 d73b74dfb..a908c96c5 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 edb2c799a..adb088ead 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 f2dd52ba4..5fb761168 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 bb9b97099..13ccd2672 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 33c1dc7a7..ee24e8070 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 1e8ac3ae3..3ba3e4ae6 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 3f3b92da4..5669113d6 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 ad93147e5..6b24db617 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,8 @@ -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 { isValidDate } from "../_internals/is-valid-date/is-valid-date"; +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 +11,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 (!isValidDate(laterDate)) return null; + if (!isValidDate(earlierDate)) 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 68925f649..78c4581f6 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 f643f5be4..3be6ad91b 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 b1eb6bb55..d202767f2 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 c11157f98..345a9265c 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 a4fc7575c..7df7bbf5a 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 38ae15e0c..8717f11f0 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 fc0910f4a..cfe354a65 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 4e2b5d50c..45add4aba 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 989348a6e..72d6b3f5a 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 7de892705..255ae9c7a 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 55e7b54a7..ba2f2afa4 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 ba966cab5..1c9a5d9e0 100644 --- a/src/format-cnpj/format-cnpj.ts +++ b/src/format-cnpj/format-cnpj.ts @@ -1,7 +1,6 @@ import { format } from "../_internals/format/format"; import { isNullish } from "../_internals/is-nullish/is-nullish"; -import { sanitizeToAlphanumeric } from "../_internals/sanitize-to-alphanumeric/sanitize-to-alphanumeric"; -import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { sanitizeCnpj } from "../_internals/sanitize-cnpj/sanitize-cnpj"; import { OBFUSCATED_PATTERN, PATTERN } from "./constants"; /** Options of `formatCnpj`. */ @@ -10,18 +9,10 @@ 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; }; -const sanitize = (value: string | number, version?: FormatCnpjOptions["version"]): string => { - if (version === 2) { - return sanitizeToAlphanumeric(value); - } - - return sanitizeToDigits(value); -}; - /** * Formats a given CNPJ (Cadastro Nacional da Pessoa Jurídica) value according to the specified options. * @@ -29,7 +20,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 @@ -51,7 +43,7 @@ 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, + value: sanitizeCnpj(value, options?.version), + 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 687cbbefc..e26280fd5 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 deea847be..48931c168 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 ea39c6289..573950eff 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 d949c01af..31faa6b00 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 a96880fcb..c6f7945ee 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 01fd616b6..117a218b3 100644 --- a/src/format-currency/format-currency.ts +++ b/src/format-currency/format-currency.ts @@ -9,11 +9,9 @@ export type FormatCurrencyOptions = { precision?: number; }; -let formatters: Map | undefined; +const formatters = new Map(); const getFormatter = (symbol: boolean, precision: number): Intl.NumberFormat => { - formatters ??= new Map(); - const key = `${symbol}|${precision}`; const cached = formatters.get(key); @@ -34,10 +32,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 +51,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 +65,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 +85,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 c64d8fc6e..d584a4536 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 2a4b6d708..c41fedecb 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 d13ed4e21..929c8680b 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 8283f3884..fd0728112 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 a425a751b..d92fc9cfa 100644 --- a/src/format-license-plate/format-license-plate.ts +++ b/src/format-license-plate/format-license-plate.ts @@ -19,12 +19,18 @@ 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); + const head = parsed.slice(0, OLD_FORMAT_SEPARATOR_INDEX); - if (!/^[A-Z]{1,3}$/.test(parsed.slice(0, Math.min(parsed.length, OLD_FORMAT_SEPARATOR_INDEX)))) { + if (!/^[A-Z]{1,3}$/.test(head)) { return ""; } @@ -33,7 +39,7 @@ export const formatLicensePlate = (value: string): string => { const tail = parsed.slice(OLD_FORMAT_SEPARATOR_INDEX); if (/^\d{1,4}$/.test(tail)) { - return `${parsed.slice(0, OLD_FORMAT_SEPARATOR_INDEX)}-${tail}`; + return `${head}-${tail}`; } if (/^\d[A-Z]\d{0,2}$/.test(tail)) { diff --git a/src/format-ncm/format-ncm.test.ts b/src/format-ncm/format-ncm.test.ts index 9426e236d..47fcbf02e 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 c9076b2ed..c78ccbd0c 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 68ae4622a..87e57578a 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 bc25ee879..ade8d888a 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 5fd38f116..df742b397 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/constants.ts b/src/format-phone/constants.ts index 8d3b49e86..c332d91a1 100644 --- a/src/format-phone/constants.ts +++ b/src/format-phone/constants.ts @@ -17,10 +17,8 @@ export const PHONE_MASKS: ReadonlySet = new Set([ /** The mask `formatPhone` applies when `options.mask` is missing or is not a `PhoneMask`. */ export const DEFAULT_MASK = "sn"; -export const LENGTH: Record = { - sn: 9, - nanp: 11, -}; +/** Length of a bare Brazilian subscriber number, the boundary the `"auto"` mask reads. */ +export const SN_LENGTH = 9; export const MASK: Record = { sn: "00000-0000", diff --git a/src/format-phone/format-phone.ts b/src/format-phone/format-phone.ts index d30e6e1d7..92bf9ac1b 100644 --- a/src/format-phone/format-phone.ts +++ b/src/format-phone/format-phone.ts @@ -1,11 +1,9 @@ import { PHONE_NATIONAL_MIN_LENGTH } from "../_internals/constants/phone"; import { - SERVICE_PHONE_ABBREVIATED_ROOT_LENGTH, SERVICE_PHONE_ABBREVIATED_ROOTS, 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"; @@ -14,12 +12,12 @@ import { DEFAULT_MASK, INTERNATIONAL_MASK, INTERNATIONAL_PREFIX, - LENGTH, MASK, NANP_LANDLINE_MASK, type NationalMask, PHONE_MASKS, SERVICE_MASK, + SN_LENGTH, } from "./constants"; /** The masks `formatPhone` can apply. */ @@ -32,23 +30,22 @@ export type FormatPhoneOptions = { }; const matchesPrefix = (digits: string, prefixes: readonly string[]): boolean => - prefixes.some((prefix) => - // Stryker disable next-line ConditionalExpression,EqualityOperator,MethodExpression: every prefix list used here shares one prefix length, and both service masks only emit their first separator once the value is longer than that shared length, so this "still typing" branch can never change formatService's output, and the boundary (digits.length === prefix.length) reduces to the same string equality either way - digits.length < prefix.length ? prefix.startsWith(digits) : digits.startsWith(prefix), - ); + prefixes.some((prefix) => digits.startsWith(prefix)); +/** + * A value still being typed is formatted as far as it goes: it is shorter than every prefix + * below, so it matches none of them and is returned as it came, which is exactly what both + * masks would print for it anyway (their first separator only appears once the value is longer + * than the prefix that selects the mask). + * @param {string} digits - The digits of a service number. + * @returns {string} The digits under the mask of their service number family. + */ const formatService = (digits: string): string => { if (matchesPrefix(digits, SERVICE_PHONE_NON_GEOGRAPHIC_PREFIXES)) { return format({ value: digits, pattern: SERVICE_MASK.nonGeographic }); } - if ( - matchesPrefix( - // Stryker disable next-line MethodExpression: digits.startsWith(prefix) already holds for the full digits if and only if it holds for digits.slice(0, ROOT_LENGTH), since a root is only ever matched at its own length - digits.slice(0, SERVICE_PHONE_ABBREVIATED_ROOT_LENGTH), - SERVICE_PHONE_ABBREVIATED_ROOTS, - ) - ) { + if (matchesPrefix(digits, SERVICE_PHONE_ABBREVIATED_ROOTS)) { return format({ value: digits, pattern: SERVICE_MASK.abbreviated }); } @@ -77,7 +74,7 @@ const resolveAutoMask = (digits: string, serviceDigits: string): Exclude LENGTH.sn ? "nanp" : "sn"; + return digits.length > SN_LENGTH ? "nanp" : "sn"; }; const isPhoneMask = (value: unknown): value is PhoneMask => PHONE_MASKS.has(value); @@ -138,8 +135,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 c766ef719..b7e6ae193 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 69fa08a09..e6054adb3 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 c1547af70..d65c9e0ec 100644 --- a/src/generate-boleto/generate-boleto.test.ts +++ b/src/generate-boleto/generate-boleto.test.ts @@ -7,11 +7,30 @@ import { formatBoleto } from "../format-boleto/format-boleto"; import { getBoletoInfo } from "../get-boleto-info/get-boleto-info"; import { isValidBoleto } from "../is-valid-boleto/is-valid-boleto"; import { parseBoleto } from "../parse-boleto/parse-boleto"; -import { type GenerateBoletoOptions, generateBoleto } from "./generate-boleto"; +import { type GenerateBoletoParams, 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)); }), ); @@ -151,12 +175,12 @@ describe("generateBoleto", () => { describe("generateBoleto types", () => { test("should take optional options and return a string", () => { - expectTypeOf(generateBoleto).parameter(0).toEqualTypeOf(); + expectTypeOf(generateBoleto).parameter(0).toEqualTypeOf(); expectTypeOf(generateBoleto).returns.toEqualTypeOf(); }); test("should restrict type to the supported boleto kinds", () => { - expectTypeOf().toEqualTypeOf< + expectTypeOf().toEqualTypeOf< "bancario" | "arrecadacao" | undefined >(); }); diff --git a/src/generate-boleto/generate-boleto.ts b/src/generate-boleto/generate-boleto.ts index 194042c59..6a46e4866 100644 --- a/src/generate-boleto/generate-boleto.ts +++ b/src/generate-boleto/generate-boleto.ts @@ -3,12 +3,17 @@ import { generateRandomNumber } from "../_internals/generate-random-number/gener import { mod10 } from "../_internals/mod10/mod10"; import { mod11 } from "../_internals/mod11/mod11"; -/** Options of `generateBoleto`. */ -export type GenerateBoletoOptions = { +/** The parameters of `generateBoleto`. */ +export type GenerateBoletoParams = { /** Which kind of bank slip to generate (default: `"bancario"`). */ 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,8 +72,12 @@ const generateArrecadacao = (): string => { * * Uses `Math.random()` internally, so it is not cryptographically secure, do not use for security purposes. * - * @param {GenerateBoletoOptions} [options] - Optional options. - * @param {string} options.type - `"bancario"` (default) or `"arrecadacao"`. + * 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 {GenerateBoletoParams} [params] - Optional parameters. + * @param {string} params.type - `"bancario"` (default) or `"arrecadacao"`. * @returns {string} A valid 47-digit boleto string without formatting, or a 48-digit one for arrecadação. * * @example @@ -72,13 +86,15 @@ 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 => - options?.type === "arrecadacao" ? generateArrecadacao() : generateBancario(); +export const generateBoleto = (params?: GenerateBoletoParams): string => + params?.type === "arrecadacao" ? generateArrecadacao() : generateBancario(); diff --git a/src/generate-cnpj/generate-cnpj.test.ts b/src/generate-cnpj/generate-cnpj.test.ts index c82a5782b..67fa9de19 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 GenerateCnpjParams, 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,28 @@ 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 | GenerateCnpjParams | 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 a8cf2d757..e9ace8f4f 100644 --- a/src/generate-cnpj/generate-cnpj.ts +++ b/src/generate-cnpj/generate-cnpj.ts @@ -1,23 +1,61 @@ +import { calculateCnpjCheckDigit } from "../_internals/calculate-cnpj-check-digit/calculate-cnpj-check-digit"; import { CNPJ_FIRST_DIGIT_WEIGHTS, CNPJ_SECOND_DIGIT_WEIGHTS } from "../_internals/constants/cnpj"; -import { generateChecksum } from "../_internals/generate-checksum/generate-checksum"; 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 parameters `generateCnpj` accepts, an alternative to passing the version positionally. + */ +export type GenerateCnpjParams = { + /** + * 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; }; +// `Number.isInteger` as a type guard, so an out of range `branch` narrows to `number`. +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)) { @@ -26,57 +64,63 @@ const generateNonRepeatedBase = (generate: () => string): string => { return base; }; -const charToCnpjValue = (char: string): number => char.charCodeAt(0) - 48; - -const generateAlphanumericChecksum = (cnpj: string, weights: number[]): number => - weights.reduce((sum, weight, index) => sum + charToCnpjValue(cnpj.charAt(index)) * weight, 0); - -const calculateCheckDigit = (base: string, weights: number[]): string => { - const mod = generateChecksum({ base, weight: weights }) % 11; - return (mod < 2 ? 0 : 11 - mod).toString(); -}; - -const calculateAlphanumericCheckDigit = (base: string, weights: number[]): string => { - const mod = generateAlphanumericChecksum(base, weights) % 11; - return (mod < 2 ? 0 : 11 - mod).toString(); -}; - -const generateNumericCnpj = (): string => { - const base = generateNonRepeatedBase(() => generateRandomNumber(BASE_LENGTH)); - 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 firstCheckDigit = calculateAlphanumericCheckDigit(base, CNPJ_FIRST_DIGIT_WEIGHTS); - const secondCheckDigit = calculateAlphanumericCheckDigit( - base + firstCheckDigit, - CNPJ_SECOND_DIGIT_WEIGHTS, +const generateCnpjWith = ( + branch: number | undefined, + generatePart: (length: number) => string, +): string => { + const base = generateNonRepeatedBase(() => generateBase(branch, generatePart)); + const firstCheckDigit = String(calculateCnpjCheckDigit(base, CNPJ_FIRST_DIGIT_WEIGHTS)); + const secondCheckDigit = String( + calculateCnpjCheckDigit(base + firstCheckDigit, CNPJ_SECOND_DIGIT_WEIGHTS), ); return base + firstCheckDigit + secondCheckDigit; }; +const isGenerateCnpjParams = ( + versionOrParams: 1 | 2 | GenerateCnpjParams, +): versionOrParams is GenerateCnpjParams => + typeof versionOrParams === "object" && versionOrParams !== 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 `GenerateCnpjParams` + * object carrying that same version plus the "número de ordem" (filial) block to write in + * positions 9 to 12. + * + * @param {1 | 2 | GenerateCnpjParams} [versionOrParams] - 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} [versionOrParams.version] - The version of the CNPJ to be generated, as above. + * @param {number} [versionOrParams.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 = (versionOrParams: 1 | 2 | GenerateCnpjParams = 1): string => { + const params: GenerateCnpjParams = isGenerateCnpjParams(versionOrParams) + ? versionOrParams + : { version: versionOrParams }; + + return generateCnpjWith( + params.branch, + params.version === 2 ? generateRandomCnpjChars : generateRandomNumber, + ); +}; diff --git a/src/generate-cpf/generate-cpf.test.ts b/src/generate-cpf/generate-cpf.test.ts index 8b92d810a..63beca5b9 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 115a1e104..2d9b8798a 100644 --- a/src/generate-cpf/generate-cpf.ts +++ b/src/generate-cpf/generate-cpf.ts @@ -1,25 +1,33 @@ +import { calculateCpfCheckDigit } from "../_internals/calculate-cpf-check-digit/calculate-cpf-check-digit"; import { type StateCode } from "../_internals/constants/states"; -import { generateChecksum } from "../_internals/generate-checksum/generate-checksum"; import { generateRandomNumber } from "../_internals/generate-random-number/generate-random-number"; 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); }; -const calculateCheckDigit = (base: string, weight: number): string => { - const mod = generateChecksum({ base, weight }) % 11; - return (mod < 2 ? 0 : 11 - mod).toString(); -}; - /** * Generates a valid random CPF (Cadastro de Pessoas Físicas). * * 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 +36,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); @@ -39,7 +58,7 @@ export const generateCpf = (state?: StateCode): string => { base = generateRandomNumber(BASE_LENGTH) + getStateCode(state); } - const firstCheckDigit = calculateCheckDigit(base, 10); - const secondCheckDigit = calculateCheckDigit(base + firstCheckDigit, 11); + const firstCheckDigit = String(calculateCpfCheckDigit(base)); + const secondCheckDigit = String(calculateCpfCheckDigit(base + firstCheckDigit)); return base + firstCheckDigit + secondCheckDigit; }; diff --git a/src/generate-legal-nature/generate-legal-nature.test.ts b/src/generate-legal-nature/generate-legal-nature.test.ts index ca395b049..02dd6af55 100644 --- a/src/generate-legal-nature/generate-legal-nature.test.ts +++ b/src/generate-legal-nature/generate-legal-nature.test.ts @@ -1,6 +1,8 @@ import * as fc from "fast-check"; import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; +import { getLegalNature } from "../get-legal-nature/get-legal-nature"; +import { LEGACY_LEGAL_NATURE } from "../is-valid-legal-nature/constants"; import { isValidLegalNature } from "../is-valid-legal-nature/is-valid-legal-nature"; import { generateLegalNature } from "./generate-legal-nature"; @@ -17,12 +19,29 @@ describe("generateLegalNature", () => { Math.random = () => 0.5; try { - expect(generateLegalNature()).toBe("2216"); + expect(generateLegalNature()).toBe("2194"); } finally { Math.random = originalRandom; } }); + it("should never draw a code a past CONCLA revision retired", () => { + const originalRandom = Math.random; + const drawn: string[] = []; + + try { + for (let index = 0; index < 92; index++) { + Math.random = () => (index + 0.5) / 92; + drawn.push(generateLegalNature()); + } + } finally { + Math.random = originalRandom; + } + + expect(new Set(drawn).size).toBe(92); + expect(drawn.some((code) => Object.hasOwn(LEGACY_LEGAL_NATURE, code))).toBe(false); + }); + describe("properties", () => { const batchSize = fc.integer({ min: 1, max: 20 }); @@ -34,6 +53,7 @@ describe("generateLegalNature", () => { expect(code).toMatch(/^\d{4}$/); expect(isValidLegalNature(code)).toBe(true); + expect(getLegalNature(code)?.legacy).toBe(false); } }), ); diff --git a/src/generate-legal-nature/generate-legal-nature.ts b/src/generate-legal-nature/generate-legal-nature.ts index 72cce239e..b4d9a0598 100644 --- a/src/generate-legal-nature/generate-legal-nature.ts +++ b/src/generate-legal-nature/generate-legal-nature.ts @@ -1,3 +1,4 @@ +import { isLegacyLegalNature } from "../_internals/is-legacy-legal-nature/is-legacy-legal-nature"; import { LEGAL_NATURE } from "../is-valid-legal-nature/constants"; /** @@ -5,18 +6,25 @@ import { LEGAL_NATURE } from "../is-valid-legal-nature/constants"; * * Uses `Math.random()` internally, so it is not cryptographically secure, do not use for security purposes. * - * @returns {string} One of the legal nature codes published by the CONCLA. + * Only the 92 codes of the Tabela de Natureza Jurídica 2021 are drawn: a code a past revision of + * the table retired stays valid for `isValidLegalNature`, but is never generated. + * + * @returns {string} One of the legal nature codes in force published by the CONCLA. * * @example * ```typescript * 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 */ export const generateLegalNature = (): string => { - const legalNatureCodes = Object.keys(LEGAL_NATURE); + const legalNatureCodes = Object.keys(LEGAL_NATURE).filter((code) => !isLegacyLegalNature(code)); return legalNatureCodes[Math.floor(Math.random() * legalNatureCodes.length)]; }; diff --git a/src/generate-license-plate/generate-license-plate.test.ts b/src/generate-license-plate/generate-license-plate.test.ts index 4d1f4edf7..63195cf54 100644 --- a/src/generate-license-plate/generate-license-plate.test.ts +++ b/src/generate-license-plate/generate-license-plate.test.ts @@ -36,6 +36,16 @@ describe("generateLicensePlate", () => { expect(plate).toMatch(/^[A-Z]{3}\d[A-Z]\d{2}$/); }); + it("should fall back to the mercosul format for a format string the CONTRAN does not keep in circulation", () => { + for (const format of ["LLLNNLN", "bogus", "", "lllnlnn"]) { + // @ts-expect-error: intentionally invalid input + const plate = generateLicensePlate(format); + + expect(plate).toMatch(/^[A-Z]{3}\d[A-Z]\d{2}$/); + expect(isValidLicensePlate(plate)).toBe(true); + } + }); + it("should fall back to the mercosul format when the argument is not a string", () => { // @ts-expect-error: intentionally invalid input expect(generateLicensePlate(123)).toMatch(/^[A-Z]{3}\d[A-Z]\d{2}$/); diff --git a/src/generate-license-plate/generate-license-plate.ts b/src/generate-license-plate/generate-license-plate.ts index 946ad6e28..b58dfc370 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,12 +28,27 @@ 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` outside the two supported literals falls back to the default, like every other + * generator of this package does with an option it does not know, so the result is always a plate + * `isValidLicensePlate` accepts. (2.3.0 used an unknown string verbatim, so + * `generateLicensePlate("LLLNNLN")` produced the withdrawn motorcycle sequence and + * `generateLicensePlate("bogus")` five digits; neither is a plate.) + * * @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, ): string => { - const safeFormat = typeof format === "string" ? format : DEFAULT_FORMAT; + // Only the old sequence needs naming: the Mercosul one is the default, and anything else falls + // back to it, as every generator of this package does with an option it does not know. + const safeFormat = format === "LLLNNNN" ? format : DEFAULT_FORMAT; let plate = ""; diff --git a/src/generate-passport/generate-passport.ts b/src/generate-passport/generate-passport.ts index 05c905e12..6e1f63fc7 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-phone/generate-phone.ts b/src/generate-phone/generate-phone.ts index 7e00453f5..2055dec18 100644 --- a/src/generate-phone/generate-phone.ts +++ b/src/generate-phone/generate-phone.ts @@ -8,24 +8,22 @@ import { SERVICE_PHONE_NON_GEOGRAPHIC_PREFIXES, } from "../_internals/constants/service-phone"; import { generateRandomNumber } from "../_internals/generate-random-number/generate-random-number"; +import { pickRandom } from "../_internals/pick-random/pick-random"; /** The kinds of phone number `generatePhone` can generate. */ export type GeneratePhoneType = "mobile" | "landline" | "service"; -const randomFrom = (list: readonly Item[]): Item => - list[Math.floor(Math.random() * list.length)]; - -const randomAreaCode = (): string => randomFrom(VALID_AREA_CODES).toString(); +const randomAreaCode = (): string => pickRandom(VALID_AREA_CODES).toString(); const randomServicePhone = (): string => { if (Math.random() >= 0.5) { - const prefix = randomFrom(SERVICE_PHONE_NON_GEOGRAPHIC_PREFIXES); + const prefix = pickRandom(SERVICE_PHONE_NON_GEOGRAPHIC_PREFIXES); const rest = SERVICE_PHONE_NON_GEOGRAPHIC_LENGTH - SERVICE_PHONE_NON_GEOGRAPHIC_PREFIX_LENGTH; return `${prefix}${generateRandomNumber(rest)}`; } - const root = randomFrom(SERVICE_PHONE_ABBREVIATED_ROOTS); + const root = pickRandom(SERVICE_PHONE_ABBREVIATED_ROOTS); const rest = SERVICE_PHONE_ABBREVIATED_LENGTH - SERVICE_PHONE_ABBREVIATED_ROOT_LENGTH; return `${root}${generateRandomNumber(rest)}`; @@ -56,19 +54,17 @@ const randomServicePhone = (): string => { * @see Official: https://informacoes.anatel.gov.br/legislacao/resolucoes/2022/1641-resolucao-749 */ export const generatePhone = (type?: GeneratePhoneType): string => { - const areaCode = randomAreaCode(); - - if (type === "landline") { - return `${areaCode}${2 + Math.floor(Math.random() * 5)}${generateRandomNumber(7)}`; + if (type === "service") { + return randomServicePhone(); } - if (type === "mobile") { - return `${areaCode}9${generateRandomNumber(8)}`; - } + const areaCode = randomAreaCode(); + // Without a type, a coin flip picks the line; no self-call, so the choice is made once here. + const isLandline = type === "landline" || (type !== "mobile" && Math.random() < 0.5); - if (type === "service") { - return randomServicePhone(); + if (isLandline) { + return `${areaCode}${2 + Math.floor(Math.random() * 5)}${generateRandomNumber(7)}`; } - return Math.random() >= 0.5 ? generatePhone("mobile") : generatePhone("landline"); + return `${areaCode}9${generateRandomNumber(8)}`; }; diff --git a/src/generate-pis/generate-pis.ts b/src/generate-pis/generate-pis.ts index aff44f649..7d85a6cc7 100644 --- a/src/generate-pis/generate-pis.ts +++ b/src/generate-pis/generate-pis.ts @@ -1,16 +1,7 @@ -import { PIS_WEIGHTS } from "../_internals/constants/pis"; +import { calculatePisCheckDigit } from "../_internals/calculate-pis-check-digit/calculate-pis-check-digit"; import { generateRandomNumber } from "../_internals/generate-random-number/generate-random-number"; import { isRepeatedDigits } from "../_internals/is-repeated-digits/is-repeated-digits"; -const calculateCheckDigit = (base: string): string => { - const sum = PIS_WEIGHTS.reduce( - (acc, weight, index) => acc + Number(base.charAt(index)) * weight, - 0, - ); - const digit = 11 - (sum % 11); - return digit >= 10 ? "0" : digit.toString(); -}; - /** * Generates a valid random Brazilian PIS (Programa de Integração Social) number. * @@ -39,5 +30,5 @@ export const generatePis = (): string => { base = generateRandomNumber(10); } - return `${base}${calculateCheckDigit(base)}`; + return `${base}${calculatePisCheckDigit(base)}`; }; diff --git a/src/generate-pix-payload/generate-pix-payload.test.ts b/src/generate-pix-payload/generate-pix-payload.test.ts index da8b1f812..017e43ef5 100644 --- a/src/generate-pix-payload/generate-pix-payload.test.ts +++ b/src/generate-pix-payload/generate-pix-payload.test.ts @@ -4,8 +4,11 @@ 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"; const BASE = { @@ -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,21 +363,21 @@ 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"); }); }); @@ -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); diff --git a/src/generate-pix-payload/generate-pix-payload.ts b/src/generate-pix-payload/generate-pix-payload.ts index a6640ab42..13c5c72e8 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, @@ -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 @@ -202,8 +203,10 @@ 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 => { if (isNullish(params) || typeof params !== "object") return null; diff --git a/src/generate-processo-juridico/generate-processo-juridico.test.ts b/src/generate-processo-juridico/generate-processo-juridico.test.ts index 92adccc5a..9cb7f303c 100644 --- a/src/generate-processo-juridico/generate-processo-juridico.test.ts +++ b/src/generate-processo-juridico/generate-processo-juridico.test.ts @@ -1,11 +1,14 @@ 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 { generateProcessoJuridico, - type GenerateProcessoJuridicoOptions, + type GenerateProcessoJuridicoParams, } from "./generate-processo-juridico"; const currentYear = (): number => new Date().getFullYear(); @@ -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 }) @@ -133,12 +186,12 @@ describe("generateProcessoJuridico types", () => { test("should take options and return a string or null", () => { expectTypeOf(generateProcessoJuridico) .parameter(0) - .toEqualTypeOf(); + .toEqualTypeOf(); expectTypeOf(generateProcessoJuridico).returns.toEqualTypeOf(); }); test("should type the year and court options as optional numbers", () => { - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); }); }); diff --git a/src/generate-processo-juridico/generate-processo-juridico.ts b/src/generate-processo-juridico/generate-processo-juridico.ts index 45050b779..0dcaf2dad 100644 --- a/src/generate-processo-juridico/generate-processo-juridico.ts +++ b/src/generate-processo-juridico/generate-processo-juridico.ts @@ -1,30 +1,44 @@ +import { calculateProcessoJuridicoCheckDigits } from "../_internals/calculate-processo-juridico-check-digits/calculate-processo-juridico-check-digits"; +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"; +import { pickRandom } from "../_internals/pick-random/pick-random"; -/** Options of `generateProcessoJuridico`. */ -export type GenerateProcessoJuridicoOptions = { +/** The parameters of `generateProcessoJuridico`. */ +export type GenerateProcessoJuridicoParams = { /** Filing year, from the current year to 9999 (default: the current year). */ year?: number; /** Court segment (J), from 1 to 9 (default: random). */ court?: number; }; +/** + * The parameters of `generateProcessoJuridico`, the 2.3.0 name of + * `GenerateProcessoJuridicoParams`. + * + * @deprecated Use `GenerateProcessoJuridicoParams` instead. + */ +export type GenerateProcessoJuridicoOptions = GenerateProcessoJuridicoParams; + const MAX_YEAR = 9999; -const MIN_COURT = 1; -const MAX_COURT = 9; +const TRIBUNAL_LENGTH = 2; -const calculateCheckDigits = (base: string): string => { - const checksum = 98n - ((BigInt(base) * 100n) % 97n); - return checksum.toString().padStart(2, "0"); -}; +const COURTS = [...PROCESSO_JURIDICO_TRIBUNALS.keys()]; /** * 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. + * @param {GenerateProcessoJuridicoParams} [options] - Optional generation options. * @param {number} options.year - The `AAAA` field. Must be an integer between the * current year and 9999. Defaults to the current year. * @param {number} options.court - The `J` field (segmento do Judiciário). Must be an @@ -33,39 +47,35 @@ 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 */ export const generateProcessoJuridico = ( - options: GenerateProcessoJuridicoOptions = {}, + options: GenerateProcessoJuridicoParams = {}, ): string | null => { if (isNullish(options) || typeof options !== "object") return null; - const { year = new Date().getFullYear(), court = Math.floor(Math.random() * 9) + 1 } = options; const currentYear = new Date().getFullYear(); + const { year = currentYear, court = pickRandom(COURTS) } = options; + 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(pickRandom(tribunals)).padStart(TRIBUNAL_LENGTH, "0"); const foro = generateRandomNumber(4); const base = `${sequencial}${year}${court}${tribunal}${foro}`; - const checkDigits = calculateCheckDigits(base); + const checkDigits = String(calculateProcessoJuridicoCheckDigits(base)).padStart(2, "0"); return `${sequencial}${checkDigits}${year}${court}${tribunal}${foro}`; }; diff --git a/src/generate-renavam/generate-renavam.test.ts b/src/generate-renavam/generate-renavam.test.ts new file mode 100644 index 000000000..081c3ebb5 --- /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 000000000..1fd230711 --- /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 cead4fe46..3d589cd87 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 57102d978..8dbb1a964 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 1bf2a67ac..14e7396a9 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 21508aa6d..39489e34f 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 a1b1ce1eb..cc18dfeb5 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 aa2440ee3..c1a16466c 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 * ``` @@ -65,18 +79,17 @@ export const getAreaCodeInfo = (areaCode: string | number): AreaCodeInfo | null const stateCode = AREA_CODE_STATES[numericAreaCode]; - if (stateCode === undefined) return null; - - const statesByCode: Record = {}; - for (const entry of DATA) statesByCode[entry.code] = entry; + // An unknown DDD maps to no state code, so the lookup below finds no state for it either. + const state = DATA.find((entry) => entry.code === stateCode); - const state = statesByCode[stateCode]; + if (state === undefined) return null; return { 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 04a8f88ca..5bcd23493 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 add899359..199680db2 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 cefd3156b..2fc3d7c80 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 345f19cf9..a8b4e577c 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 57378edff..76aabaccf 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 adf592b7f..b4fca4845 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 6592c39fa..34403fd92 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, @@ -33,6 +34,12 @@ export type BoletoInfo = { const toDayNumber = (date: Date): number => Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / DAY_IN_MS); +/** + * The day number of the cycle's base date, the fixed point every fator de vencimento counts + * from. Computed per call on purpose: as a module level constant it would be a top level call no + * consumer bundler can prove pure, which pins this module into every export's bundle. + * @returns {number} The day number of the base date. + */ const getBaseDayNumber = (): number => Math.floor(Date.UTC(BASE_DATE_YEAR, BASE_DATE_MONTH, BASE_DATE_DAY) / DAY_IN_MS); @@ -40,10 +47,13 @@ const dateFromBase = (days: number): Date => new Date(BASE_DATE_YEAR, BASE_DATE_MONTH, BASE_DATE_DAY + days); const getExpirationDate = (factor: number, referenceDate: Date): Date | null => { - if (!Number.isFinite(factor) || factor < MIN_FACTOR) return null; + if (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 +89,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); @@ -136,7 +167,7 @@ export const getBoletoInfo = ( options?.referenceDate ?? new Date(), ); - const amount = Number(sanitized.slice(37, 47)) || 0; + const amount = Number(sanitized.slice(37, 47)); return { amount, expirationDate, bankCode }; }; diff --git a/src/get-cbo/get-cbo.test.ts b/src/get-cbo/get-cbo.test.ts index d0c2b0703..4d9c21c66 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 9e7cfadeb..54025c041 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 5b3b5f6e8..8dece4493 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 @@ -10,7 +10,7 @@ import { import { type CepAddressInfo, GetCepInfoByAddressError, - type GetCepInfoByAddressOptions, + type GetCepInfoByAddressParams, GetCepInfoByAddressNotFoundError, GetCepInfoByAddressValidationError, getCepInfoByAddress, @@ -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]); @@ -219,8 +267,8 @@ describe("getCepInfoByAddress", () => { describe("getCepInfoByAddress types", () => { it("should take the address options and resolve to a list of CepAddressInfo", () => { - expectTypeOf(getCepInfoByAddress).parameter(0).toEqualTypeOf(); - expectTypeOf().toEqualTypeOf<{ + expectTypeOf(getCepInfoByAddress).parameter(0).toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<{ federalUnit: string; city: string; street: string; 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 0b1d2ea45..542ead3eb 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 { isStateCode } from "../_internals/is-state-code/is-state-code"; 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. */ @@ -51,7 +62,7 @@ export type CepAddressInfo = { }; /** The address `getCepInfoByAddress` looks up. */ -export type GetCepInfoByAddressOptions = { +export type GetCepInfoByAddressParams = { /** Two letter state code, e.g. "SP". */ federalUnit: string; /** City name. Must not be empty; ViaCEP itself rejects values shorter than 3 characters. */ @@ -60,8 +71,12 @@ export type GetCepInfoByAddressOptions = { street: string; }; -const isStateCode = (value: string): value is StateCode => - STATES.some((state) => state.code === value); +/** + * The address `getCepInfoByAddress` looks up, the 2.3.0 name of `GetCepInfoByAddressParams`. + * + * @deprecated Use `GetCepInfoByAddressParams` instead. + */ +export type GetCepInfoByAddressOptions = GetCepInfoByAddressParams; const normalizeAddressPart = (value: string): string => removeAccents(value).trim(); @@ -72,30 +87,57 @@ const isCepAddressInfoArray = (value: unknown): value is CepAddressInfo[] => Arr /** * Looks every CEP of a Brazilian street up on the ViaCEP API. * - * @param {GetCepInfoByAddressOptions} params - The address to look up. + * @param {GetCepInfoByAddressParams} params - The address to look up. * @param {string} params.federalUnit - The two letter state code (e.g. "SP"). * @param {string} params.city - The city name. * @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: GetCepInfoByAddressParams, +): 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 000000000..6ebaf08b0 --- /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 000000000..136e0bcfd --- /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 000000000..6bb9f1348 --- /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 b7ddca6c5..9abfde24e 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,13 +52,15 @@ 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. */ export const getCfop = (value: string | number): Cfop | null => { if (!isLookupCode(value)) return null; - const code = typeof value === "number" ? String(value) : value.trim(); + const code = String(value).trim(); if (!CFOP_FORMAT_REGEX.test(code)) return null; diff --git a/src/get-cities/get-cities.test.ts b/src/get-cities/get-cities.test.ts index be93e5f30..1b3aa49d5 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 81402207f..eb71c02a7 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 cf8c4af69..e286c7f35 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 ac8223526..aa28ad74c 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 cf3f122be..d70aa44ee 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 @@ -22,6 +22,13 @@ describe("getFormatLicensePlate", () => { expect(getFormatLicensePlate("invalid")).toBeNull(); }); + it("should return null for a value that is not a string, even one that stringifies to a plate", () => { + // @ts-expect-error: intentionally invalid input + expect(getFormatLicensePlate({ toString: () => "ABC1234" })).toBeNull(); + // @ts-expect-error: intentionally invalid input + expect(getFormatLicensePlate(1_234_567)).toBeNull(); + }); + describe("properties", () => { test("should name the format of every generated plate", () => { fc.assert( @@ -46,6 +53,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 9c76c3e99..2925eb688 100644 --- a/src/get-format-license-plate/get-format-license-plate.ts +++ b/src/get-format-license-plate/get-format-license-plate.ts @@ -1,6 +1,4 @@ import { sanitizeToAlphanumeric } from "../_internals/sanitize-to-alphanumeric/sanitize-to-alphanumeric"; -import { LENGTH } from "../parse-license-plate/constants"; -import { parseLicensePlate } from "../parse-license-plate/parse-license-plate"; import { MERCOSUL_REGEX, OLD_FORMAT_REGEX } from "./constants"; /** The Brazilian license plate formats `getFormatLicensePlate` can identify: the old `LLLNNNN` and the Mercosul `LLLNLNN`. */ @@ -29,14 +27,21 @@ 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); + const parsed = sanitizeToAlphanumeric(value); if (OLD_FORMAT_REGEX.test(parsed)) return "LLLNNNN"; if (MERCOSUL_REGEX.test(parsed)) return "LLLNLNN"; diff --git a/src/get-holidays/constants.ts b/src/get-holidays/constants.ts index 345daeb2b..cc10e3ea1 100644 --- a/src/get-holidays/constants.ts +++ b/src/get-holidays/constants.ts @@ -1,11 +1,9 @@ import { type StateCode } from "../_internals/constants/states"; +import { type HolidayDateRule } from "../_internals/resolve-state-holiday-date/resolve-state-holiday-date"; import { type HolidayType } from "./get-holidays"; -export type StateHolidayEntry = { +type StateHolidayEntry = HolidayDateRule & { name: string; - day?: number; - month?: number; - easterOffset?: number; type?: HolidayType; since?: number; until?: number; @@ -24,55 +22,289 @@ export const FIXED_HOLIDAYS = { export const CONSCIENCIA_NEGRA_NATIONAL_SINCE_YEAR = 2024; +/** + * The name the 20 November entries are emitted under, national and state alike. + * + * No law spells it exactly this way. Art. 1º of Lei 14.759/2023 calls the national holiday "Dia + * Nacional de Zumbi e da Consciência Negra", and the state laws behind the pre-2024 entries of + * Mato Grosso, Rio de Janeiro and Amazonas are worded alike to each other: each institutes 20 + * November as a feriado estadual and names the date after the federal commemorative one, "Dia + * Nacional da Consciência Negra" (see the `@see` entries below for the three texts). The form + * below drops a "Nacional" that would read as wrong on a state entry, is the one 2.3.0 already + * emitted for the national holiday, and keeps the name continuous across the 2023/2024 boundary + * where the state entries give way to the national one. Amapá is the exception: art. 1º of its + * Lei nº 1.169/2007 says "Dia Estadual da Consciência Negra" in so many words, so that entry + * carries the name its own law uses. + */ 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). */ +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. */ +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. */ +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. + */ +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. + */ +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. + */ +const SC_NEXT_SUNDAY_TRANSFER_SINCE_YEAR = 2005; + +/** + * Feriados estaduais, um `@see` por entrada. + * + * 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. * - * @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 + * The statutory date is what is emitted. Four 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; São Paulo moved 09/07 to + * 25/05 for 2020 alone (Lei SP nº 17.264/2020), a one-off this table does not carry. + * + * @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). + * Its ementa carries the same wording as the Mato Grosso and Rio de Janeiro laws: "INSTITUI no + * Calendário Oficial do Estado do Amazonas o dia 20 de novembro, data de aniversário da morte de + * Zumbi dos Palmares e Dia Nacional da Consciência Negra, como feriado estadual". + * @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, de 27/12/2002, Dia da Consciência Negra (state holiday until it became + * national in 2024). Art. 1º, as published in the Diário Oficial do Estado de Mato Grosso of + * 27/12/2002 (p. 6), the text the ALMT ficha técnica links: "Fica instituído o dia 20 de + * novembro, data do aniversário da morte de Zumbi dos Palmares e Dia Nacional da Consciência + * Negra, como feriado estadual"; its ementa repeats the same wording, and the ficha técnica + * records "Não consta revogação expressa". The "Lei MT nº 1.587/2002" cited for this holiday + * elsewhere is not in the ALMT norm base at all, under any norm type: 7.879/2002 is the law that + * creates it. + * @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, de 11/11/2002, Dia da Consciência Negra (state holiday until it became + * national in 2024). Art. 1º: "Fica instituído o dia 20 de novembro, data do aniversário da + * morte de Zumbi dos Palmares e dia Nacional da consciência Negra, como feriado Estadual", the + * same wording Mato Grosso's law of the same year carries. 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), the single entry of Rio Grande do + * Norte: a "Resumo da Lei" search for "feriado" in the ALRN legislation base + * (https://www.al.rn.leg.br/legislacao/pesquisa) returns this law and no other. + * @see Official: https://www.al.rn.leg.br/noticia/19157/rn-faz-519-anos-e-data-foi-criada-por-lei-estadual-em-alusao-ao-marco-de-touros + * Lei RN nº 7.831, de 30/05/2000, the "Dia do Rio Grande do Norte" (07/08), which is *not* a + * holiday and therefore has no entry. The ALRN records it as "Lei Ord. nº 7.831, de 30/05/2000" + * and describes it in the Assembleia's own reporting on the date: the deputy "propôs o projeto de + * lei instituindo o dia 7 de agosto como data do aniversário do Rio Grande do Norte. A lei 7.831 + * foi aprovada no dia 30 de maio de 2000, sancionada no dia seguinte". It creates a data + * comemorativa and nothing else; the ALRN's own ementa index does not return it for "feriado", + * and the state's 07/08 is a working day. The 07/09 "Dia do Rio Grande do Norte" this table + * carried before 2.4.0 had no law behind it at all and merely duplicated the national + * Independência do Brasil, which still makes `isHoliday` true on 07/09 for every state. The + * ALRN's own download link for the 7.831 text + * (https://www.al.rn.leg.br/storage/legislacao//Lei%20n%C2%BA%207.831.pdf) 404s, as do the links + * of every other law it holds from that year. + * @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: "AUTORIZA O PODER EXECUTIVO A ABRIR CRÉDITO SUPLEMENTAR POR ANULAÇÃO + * ... EM FAVOR DAS UNIDADES ORÇAMENTÁRIAS: DEPARTAMENTO DE ESTRADAS E RODAGEM - DER/RO, + * SECRETARIA DE ESTADO DE ASSISTÊNCIA SOCIAL - SEAS", nothing to do with holidays. + * @see Official: https://sapl.al.ro.leg.br/norma/3003 + * Lei RO nº 1.026, de 20/12/2001, the other law cited for Rondônia, whose art. 1º did create a + * second feriado estadual — "Fica instituído feriado no Estado de Rondônia, o dia 18 de junho, + * em homenagem aos evangélicos" — but which the STF struck down, so 18/06 has no entry. + * @see Official: https://portal.stf.jus.br/processos/detalhe.asp?incidente=2545186 + * STF ADI 3940, which voided that law. Decisão de julgamento of 20/03/2020, Tribunal Pleno, + * sessão virtual: "O Tribunal, por unanimidade, julgou procedente o pedido formulado na ação + * direta para declarar a inconstitucionalidade da Lei nº 1.026, de 20 de dezembro de 2001, do + * Estado de Rondônia, nos termos do voto do Relator ... Plenário, Sessão Virtual de 13.3.2020 a + * 19.3.2020"; trânsito em julgado 11/08/2020. The declaration is erga omnes and ex tunc, so the + * date is absent for every year, not only from 2020 on. + * @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/repositorio/legislacao/lei/1997/lei-9497-05.03.1997.html + * Lei SP nº 9.497, de 05/03/1997, Revolução Constitucionalista (09/07), art. 1º: "Fica + * instituído, como feriado civil, o dia 9 (nove) de julho, data magna do Estado de São Paulo, + * conforme autorizado pelo Artigo 1.º, inciso II, da Lei Federal n. 9.093, de 12 de setembro de + * 1995". The "710/1995" cited for this holiday elsewhere is the number of the projeto de lei that + * became it, not of a law. The same ALESP text records one exception this table does not model, + * because it applies to a single year: Lei SP nº 17.264, de 22/05/2020, "que determinou a + * comemoração do feriado, excepcionalmente para o ano de 2020, em 25 de maio". + * @see Official: https://www.al.sp.gov.br/repositorio/legislacao/lei/2023/lei-17746-12.09.2023.html + * Lei SP nº 17.746, de 12/09/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. Art. + * 1º: "Fica instituído, no âmbito do Estado, o dia 20 de novembro de cada ano, Dia Estadual da + * Consciência Negra, como feriado estadual". That is the Amapá wording, not the Mato Grosso one, + * so this single-year entry is the one place the table reports a holiday under + * `CONSCIENCIA_NEGRA_HOLIDAY_NAME` where the law itself says "Dia Estadual". + * @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 +317,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,13 +357,19 @@ 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: [ { - name: LEGACY_CONSCIENCIA_NEGRA_HOLIDAY_NAME, + name: CONSCIENCIA_NEGRA_HOLIDAY_NAME, day: 20, month: 11, until: CONSCIENCIA_NEGRA_NATIONAL_SINCE_YEAR, @@ -128,12 +378,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 }], @@ -141,7 +392,7 @@ export const STATE_HOLIDAYS: Partial> = { RJ: [ { name: "São Jorge", day: 23, month: 4 }, { - name: LEGACY_CONSCIENCIA_NEGRA_HOLIDAY_NAME, + name: CONSCIENCIA_NEGRA_HOLIDAY_NAME, day: 20, month: 11, until: CONSCIENCIA_NEGRA_NATIONAL_SINCE_YEAR, @@ -152,8 +403,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 +455,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 f125e0ce1..5a8b7a0ad 100644 --- a/src/get-holidays/get-holidays.test.ts +++ b/src/get-holidays/get-holidays.test.ts @@ -5,7 +5,11 @@ import { DATA as STATES, type StateCode } from "../_internals/constants/states"; import { bench, describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; import { isBusinessDay } from "../is-business-day/is-business-day"; import { STATE_HOLIDAYS } from "./constants"; -import { getHolidays, type GetHolidaysOptions, type Holiday } from "./get-holidays"; +import { getHolidays, type GetHolidaysParams, 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 }); @@ -121,6 +125,14 @@ describe("getHolidays", () => { expect(getHolidays(null)).toEqual([]); }); + test("should read a prototype chain key as an unknown state code and list the national holidays only", () => { + const national = getHolidays(2024); + + for (const stateCode of ["toString", "constructor", "__proto__", "hasOwnProperty"]) { + expect(getHolidays({ year: 2024, stateCode: stateCode as StateCode })).toEqual(national); + } + }); + test('should return an empty array when called with a function, even one carrying a year property (typeof yearOrOptions !== "object" must reject it, not just isNullish)', () => { const fakeOptions = Object.assign(() => null, { year: 2024 }); @@ -159,13 +171,13 @@ describe("getHolidays", () => { expect(second).not.toEqual(first); }); - test("should serve a second identical call from the cache without recomputing (verified by corrupting the state holiday data in between; recomputing would throw)", () => { + test("should serve a second identical call from the cache without recomputing (verified by adding a state holiday in between; recomputing would list it)", () => { const year = 2085; const stateCode = "AC" as const; const first = getHolidays({ year, stateCode }); const acEntries = STATE_HOLIDAYS.AC ?? []; - acEntries.push({ name: "Feriado inválido para checar o cache" }); + acEntries.push({ name: "Feriado inventado para checar o cache", day: 2, month: 1 }); try { expect(getHolidays({ year, stateCode })).toEqual(first); @@ -244,20 +256,22 @@ describe("getHolidays", () => { expect(mtHolidays.filter((h) => h.name === "Dia da Consciência Negra")).toHaveLength(1); }); - test("should keep the state-specific Consciência Negra entry before 2024", () => { + test("should keep the state-specific Consciência Negra entry before 2024, under the same name the national entry uses from 2024 on (Lei MT nº 7.879/2002 and Lei RJ nº 4.007/2002 both institute the feriado estadual naming the date 'Dia Nacional da Consciência Negra')", () => { const rjHolidays = getHolidays({ year: 2023, stateCode: "RJ" }); const mtHolidays = getHolidays({ year: 2023, stateCode: "MT" }); expect(rjHolidays).toContainEqual({ - name: "Consciência Negra", + name: "Dia da Consciência Negra", date: new Date(2023, 10, 20), type: "state", }); expect(mtHolidays).toContainEqual({ - name: "Consciência Negra", + name: "Dia da Consciência Negra", date: new Date(2023, 10, 20), type: "state", }); + expect(rjHolidays.some((h) => h.name === "Consciência Negra")).toBe(false); + expect(mtHolidays.some((h) => h.name === "Consciência Negra")).toBe(false); }); test("should return only national holidays when stateCode is not provided, while SP's holidays still contain every national holiday plus extras", () => { @@ -340,21 +354,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 +381,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" }); @@ -388,11 +620,34 @@ describe("getHolidays", () => { ]); expect(prHolidays.some((h) => h.name === "Dia de Nossa Senhora do Rocio")).toBe(false); expect(rnHolidays.some((h) => h.name === "Dia do Rio Grande do Norte")).toBe(false); + expect(rnHolidays.filter((h) => h.date.getMonth() === 7 && h.date.getDate() === 7)).toEqual([]); + expect(rnHolidays.filter((h) => h.date.getMonth() === 8 && h.date.getDate() === 7)).toEqual([ + { name: "Independência do Brasil", date: new Date(2024, 8, 7), type: "national" }, + ]); expect(rnHolidays).toContainEqual({ name: "Mártires de Cunhaú e Uruaçu", date: new Date(2024, 9, 3), type: "state", }); + expect(rnHolidays.filter((h) => h.type === "state")).toEqual([ + { name: "Mártires de Cunhaú e Uruaçu", date: new Date(2024, 9, 3), type: "state" }, + ]); + }); + + test("should not include RO's Dia dos Evangélicos (18/06): Lei RO nº 1.026/2001 created it, but STF ADI 3940 declared that law unconstitutional, so the date is absent for every year while the 04/01 data magna of Lei RO nº 2.291/2010 stays", () => { + for (const year of [2002, 2019, 2024]) { + const roHolidays = getHolidays({ year, stateCode: "RO" }); + + expect(roHolidays.some((h) => h.name === "Dia dos Evangélicos")).toBe(false); + expect(roHolidays.filter((h) => h.date.getMonth() === 5 && h.date.getDate() === 18)).toEqual( + [], + ); + expect(roHolidays).toContainEqual({ + name: "Criação do Estado de Rondônia", + date: new Date(year, 0, 4), + type: "state", + }); + } }); test("should no longer include state holidays that lack a statewide legal basis: CE's São José (municipal, Fortaleza's patron saint), GO's Dia do Estado and Nossa Senhora Sant'Ana (no state law found), MT's Criação do Estado de Mato Grosso (Mato Grosso's only state holiday by law is Dia da Consciência Negra), and RJ's São Sebastião (municipal, city of Rio de Janeiro's patron saint)", () => { @@ -436,13 +691,27 @@ 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(); }), ); }); + + test("should return the national holidays for a state code that is not a string, an object without a primitive value included", () => { + const national = getHolidays(2024); + + expect(getHolidays({ year: 2024, stateCode: Object.create(null) as never })).toEqual( + national, + ); + expect(getHolidays({ year: 2024, stateCode: ["SP"] as never })).toEqual(national); + }); }); }); @@ -451,8 +720,8 @@ describe("getHolidays types", () => { expectTypeOf(getHolidays(2024)).toEqualTypeOf(); }); - test("should accept a GetHolidaysOptions and return an array of Holiday", () => { - expectTypeOf().toEqualTypeOf<{ year: number; stateCode?: StateCode }>(); + test("should accept a GetHolidaysParams and return an array of Holiday", () => { + expectTypeOf().toEqualTypeOf<{ year: number; stateCode?: StateCode }>(); expectTypeOf(getHolidays({ year: 2024, stateCode: "SP" })).toEqualTypeOf(); }); diff --git a/src/get-holidays/get-holidays.ts b/src/get-holidays/get-holidays.ts index 6007793d8..32a51cb13 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"; @@ -22,15 +24,22 @@ export type Holiday = { type: HolidayType; }; -/** The options form `getHolidays` accepts, naming the year to list and, optionally, the state whose holidays are added. */ -export type GetHolidaysOptions = { +/** The object form `getHolidays` accepts, naming the year to list and, optionally, the state whose holidays are added. */ +export type GetHolidaysParams = { /** The four digit year to list holidays for. Must be an integer between 1900 and 2099. */ year: number; /** Two letter state code whose state holidays are added to the national ones (default: national holidays only). */ stateCode?: StateCode; }; -let cache: Map | undefined; +/** + * The object form `getHolidays` accepts, the 2.3.0 name of `GetHolidaysParams`. + * + * @deprecated Use `GetHolidaysParams` instead. + */ +export type GetHolidaysOptions = GetHolidaysParams; + +const cache = new Map(); const cloneHolidays = (holidays: Holiday[]): Holiday[] => holidays.map((holiday) => ({ ...holiday, date: new Date(holiday.date) })); @@ -79,22 +88,36 @@ 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. `getHolidays` only passes a string or `undefined` down here. + // Stryker disable next-line ConditionalExpression: `Object.hasOwn` reads an `undefined` key as the string "undefined", which is no state code either, so the guard only narrows the type. + const hasStateHolidays = stateCode !== undefined && Object.hasOwn(STATE_HOLIDAYS, stateCode); + + const stateHolidays = hasStateHolidays ? STATE_HOLIDAYS[stateCode] : undefined; + + 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; + + 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 +140,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,34 +173,55 @@ 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[]; /** * Retrieves all Brazilian holidays for a given year, optionally including the holidays of a * state. See the overload taking a year for the full documentation. * - * @param {GetHolidaysOptions} options - The year to list holidays for and, optionally, the state whose holidays are added + * @param {GetHolidaysParams} options - The year to list holidays for and, optionally, the state whose holidays are added * @returns {Holiday[]} An array of holidays sorted by date */ -export function getHolidays(options: GetHolidaysOptions): Holiday[]; -export function getHolidays(yearOrOptions: number | GetHolidaysOptions): Holiday[] { +export function getHolidays(options: GetHolidaysParams): Holiday[]; +export function getHolidays(yearOrOptions: number | GetHolidaysParams): Holiday[] { let year: number; let stateCode: StateCode | undefined; @@ -166,7 +229,6 @@ export function getHolidays(yearOrOptions: number | GetHolidaysOptions): Holiday year = yearOrOptions; stateCode = undefined; } else { - // Stryker disable next-line BlockStatement: an empty block here still falls through to the `!Number.isInteger(year)` guard below, which returns [] anyway since `year` stays unassigned (undefined) if (isNullish(yearOrOptions) || typeof yearOrOptions !== "object") { return []; } @@ -183,8 +245,6 @@ export function getHolidays(yearOrOptions: number | GetHolidaysOptions): Holiday // Stryker disable next-line StringLiteral: the exact fallback text is never observable outside this module; it only has to be a value no real StateCode equals, which any fixed string satisfies const cacheKey = `${year}|${normalizedStateCode ?? ""}`; - cache ??= new Map(); - const cached = cache.get(cacheKey); if (cached) { return cloneHolidays(cached); 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 000000000..2d74cd34d --- /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 000000000..f72da77b7 --- /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 d68d02135..9f49814c5 100644 --- a/src/get-legal-nature/get-legal-nature.test.ts +++ b/src/get-legal-nature/get-legal-nature.test.ts @@ -1,54 +1,123 @@ 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 { LEGACY_LEGAL_NATURE, 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" }, + legacy: false, +}; 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", }); }); + it("should tag a code in force as not legacy, with no currentCode", () => { + const entry = getLegalNature("2070"); + + expect(entry?.legacy).toBe(false); + expect(entry).not.toHaveProperty("currentCode"); + }); + + it("should tag a retired code as legacy, with the code it corresponds to today", () => { + expect(getLegalNature("2076")).toEqual({ + code: "2076", + description: "Sociedade Empresária em Nome Coletivo", + category: { code: "2", description: "Entidades Empresariais" }, + legacy: true, + currentCode: "2070", + }); + expect(getLegalNature("2208")).toEqual({ + code: "2208", + description: "Entidade Binacional Itaipu", + category: { code: "2", description: "Entidades Empresariais" }, + legacy: true, + currentCode: "2275", + }); + }); + + it("should map every retired code to the CONCLA correspondence", () => { + expect(getLegalNature("2076")).toMatchObject({ legacy: true, currentCode: "2070" }); + expect(getLegalNature("2100")).toMatchObject({ legacy: true, currentCode: null }); + expect(getLegalNature("2208")).toMatchObject({ legacy: true, currentCode: "2275" }); + expect(getLegalNature("3042")).toMatchObject({ legacy: true, currentCode: "3069" }); + expect(getLegalNature("3050")).toMatchObject({ legacy: true, currentCode: null }); + expect(getLegalNature("3093")).toMatchObject({ legacy: true, currentCode: "3999" }); + expect(getLegalNature("3123")).toMatchObject({ legacy: true, currentCode: null }); + expect(getLegalNature("5002")).toMatchObject({ legacy: true, currentCode: "5010" }); + }); + + it("should point every non null currentCode at a code in force", () => { + for (const code of Object.keys(LEGACY_LEGAL_NATURE)) { + const entry = getLegalNature(code); + + if (entry?.legacy !== true || entry.currentCode === null) continue; + + expect(getLegalNature(entry.currentCode)).toMatchObject({ legacy: false }); + } + }); + it("should return a fresh object on every call", () => { 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 +152,13 @@ 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 legacy = Object.hasOwn(LEGACY_LEGAL_NATURE, code); + const entry = { + code, + description: LEGAL_NATURE[code], + category: LEGAL_NATURE_CATEGORIES[code[0]], + ...(legacy ? { legacy, currentCode: LEGACY_LEGAL_NATURE[code] } : { legacy }), + }; expect(getLegalNature(code)).toEqual(entry); expect(getLegalNature(`${code.slice(0, 3)}-${code.slice(3)}`)).toEqual(entry); @@ -122,4 +197,18 @@ describe("getLegalNature types", () => { expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); }); + + test("should discriminate the entry on legacy and only then expose currentCode", () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf["currentCode"]>().toEqualTypeOf< + string | null + >(); + expectTypeOf>().not.toHaveProperty("currentCode"); + }); + + 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 311cc297c..9a3418766 100644 --- a/src/get-legal-nature/get-legal-nature.ts +++ b/src/get-legal-nature/get-legal-nature.ts @@ -1,44 +1,122 @@ -import { LEGAL_NATURE, MASK_REGEX } from "../is-valid-legal-nature/constants"; +import { + LEGAL_NATURE_CATEGORIES, + type LegalNatureCategory, +} from "../_internals/constants/legal-nature-categories"; +import { SEPARATORS_REGEX } from "../_internals/constants/separators"; +import { LEGACY_LEGAL_NATURE, LEGAL_NATURE } from "../is-valid-legal-nature/constants"; + +export type { LegalNatureCategory } from "../_internals/constants/legal-nature-categories"; /** * A Brazilian legal nature (natureza jurídica) entry. + * + * `legacy` discriminates the entry: `false` for the 92 codes of the CONCLA 2021 table, the ones + * in force, and `true` for the 8 a past revision of the table retired, which carry the extra + * `currentCode` field. */ export type LegalNature = { /** The 4 digit legal nature code, without formatting. */ 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; +} & ( + | { + /** `false` when the code is one of the 92 the CONCLA 2021 table publishes. */ + legacy: false; + } + | { + /** `true` when a past revision of the CONCLA table retired the code. */ + legacy: true; + /** + * The code this legacy one corresponds to today, per the CONCLA correspondence + * spreadsheets, or `null` when the revision that retired it published no successor. + */ + currentCode: string | null; + } +); -const lookUp = (code: string): LegalNature | null => { - if (!Object.hasOwn(LEGAL_NATURE, code)) return null; +/** + * Builds the entry of a code known to be in `LEGAL_NATURE`, tagging it as legacy, with the code it + * corresponds to today, when a past revision of the CONCLA table retired it. + * + * @param {string} code - The 4 digit legal nature code, without formatting. + * @param {string} description - The description `LEGAL_NATURE` holds for the code. + * @returns {LegalNature} The legal nature entry of the code. + */ +export const buildLegalNature = (code: string, description: string): LegalNature => { + const entry = { + code, + description, + category: { ...LEGAL_NATURE_CATEGORIES[code[0]] }, + }; - return { code, description: LEGAL_NATURE[code] }; + return Object.hasOwn(LEGACY_LEGAL_NATURE, code) + ? { ...entry, legacy: true, currentCode: LEGACY_LEGAL_NATURE[code] } + : { ...entry, legacy: false }; }; +const lookUp = (code: string): LegalNature | null => + Object.hasOwn(LEGAL_NATURE, code) ? buildLegalNature(code, LEGAL_NATURE[code]) : null; + /** * Looks a Brazilian legal nature (natureza jurídica) code up. * * 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. + * + * A code a past revision of the table retired is still looked up, because it keeps appearing in + * records filed while it was in force, and comes back with `legacy: true` and the `currentCode` + * it corresponds to today per the CONCLA correspondence spreadsheets (`null` when the revision + * that retired it published no successor). The 92 codes in force have `legacy: false` and no + * `currentCode`. + * * @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" }, + * // legacy: false, + * // } + * getLegalNature("2208"); + * // { + * // code: "2208", + * // description: "Entidade Binacional Itaipu", + * // category: { code: "2", description: "Entidades Empresariais" }, + * // legacy: true, + * // currentCode: "2275", + * // } + * getLegalNature("3123")?.legacy; // true (Partido Político, retired without a successor) + * getLegalNature("206-2")?.code; // "2062" + * getLegalNature(206.2)?.category.description; // "Entidades Empresariais" * getLegalNature("0000"); // null * ``` */ export const getLegalNature = (value: string | number): LegalNature | null => { if (typeof value !== "string" && typeof value !== "number") return null; - return lookUp(String(value).replace(MASK_REGEX, "")); + return lookUp(String(value).replace(SEPARATORS_REGEX, "")); }; 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 000000000..d53cf8dc8 --- /dev/null +++ b/src/get-legal-natures-by-category/get-legal-natures-by-category.test.ts @@ -0,0 +1,399 @@ +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 { LEGACY_LEGAL_NATURE, LEGAL_NATURE } from "../is-valid-legal-nature/constants"; +import { + type GetLegalNaturesByCategoryOptions, + 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" }, + legacy: false, + }, + { + code: "4022", + description: "Segurado Especial", + category: { code: "4", description: "Pessoas Físicas" }, + legacy: false, + }, + { + code: "4081", + description: "Contribuinte individual", + category: { code: "4", description: "Pessoas Físicas" }, + legacy: false, + }, + { + code: "4090", + description: "Candidato a Cargo Político Eletivo", + category: { code: "4", description: "Pessoas Físicas" }, + legacy: false, + }, + { + code: "4111", + description: "Leiloeiro", + category: { code: "4", description: "Pessoas Físicas" }, + legacy: false, + }, + { + code: "4120", + description: "Produtor Rural (Pessoa Física)", + category: { code: "4", description: "Pessoas Físicas" }, + legacy: false, + }, +]; + +const EXTRATERRITORIAL_CATEGORY = { + code: "5", + description: "Organizações Internacionais e Outras Instituições Extraterritoriais", +} as const; + +const EXTRATERRITORIAIS: LegalNature[] = [ + { + code: "5010", + description: "Organização Internacional", + category: { ...EXTRATERRITORIAL_CATEGORY }, + legacy: false, + }, + { + code: "5029", + description: "Representação Diplomática Estrangeira", + category: { ...EXTRATERRITORIAL_CATEGORY }, + legacy: false, + }, + { + code: "5037", + description: "Outras Instituições Extraterritoriais", + category: { ...EXTRATERRITORIAL_CATEGORY }, + legacy: false, + }, +]; + +const EXTRATERRITORIAIS_WITH_LEGACY: LegalNature[] = [ + { + code: "5002", + description: "Organização Internacional e Outras Instituições Extraterritoriais", + category: { ...EXTRATERRITORIAL_CATEGORY }, + legacy: true, + currentCode: "5010", + }, + ...EXTRATERRITORIAIS, +]; + +const codesOf = (category: string | number, options?: GetLegalNaturesByCategoryOptions): string[] => + getLegalNaturesByCategory(category, options).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 leave the legacy codes of the category out by default", () => { + expect(getLegalNaturesByCategory("5")).toEqual(EXTRATERRITORIAIS); + }); + + test("should list the legacy codes of the category in place with includeLegacy", () => { + expect(getLegalNaturesByCategory("5", { includeLegacy: true })).toEqual( + EXTRATERRITORIAIS_WITH_LEGACY, + ); + }); + + test("should tag a legacy entry with the code it corresponds to today", () => { + const itaipu = getLegalNaturesByCategory("2", { includeLegacy: true }).find( + (legalNature) => legalNature.code === "2208", + ); + + expect(itaipu).toEqual({ + code: "2208", + description: "Entidade Binacional Itaipu", + category: { code: "2", description: "Entidades Empresariais" }, + legacy: true, + currentCode: "2275", + }); + }); + + test("should leave the legacy codes out for an explicit includeLegacy false", () => { + expect(getLegalNaturesByCategory("5", { includeLegacy: false })).toEqual(EXTRATERRITORIAIS); + 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); + expect(getLegalNaturesByCategory(5, { includeLegacy: true })).toEqual( + EXTRATERRITORIAIS_WITH_LEGACY, + ); + }); + + 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 30 codes in force of Entidades Empresariais", () => { + expect(codesOf("2")).toEqual([ + "2011", + "2038", + "2046", + "2054", + "2062", + "2070", + "2089", + "2097", + "2127", + "2135", + "2143", + "2151", + "2160", + "2178", + "2194", + "2216", + "2224", + "2232", + "2240", + "2259", + "2267", + "2275", + "2283", + "2291", + "2305", + "2313", + "2321", + "2330", + "2348", + "2356", + ]); + }); + + test("should list the 21 codes in force of Entidades sem Fins Lucrativos", () => { + expect(codesOf("3")).toEqual([ + "3034", + "3069", + "3077", + "3085", + "3107", + "3115", + "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 list the 25 codes of Entidades sem Fins Lucrativos with includeLegacy", () => { + expect(codesOf("3", { includeLegacy: true })).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 partition the 92 codes in force across the five categories", () => { + const codes = Object.keys(LEGAL_NATURE_CATEGORIES).flatMap((category) => codesOf(category)); + + expect(codes.length).toBe(92); + expect(new Set(codes).size).toBe(92); + expect(codes.every((code) => Object.hasOwn(LEGAL_NATURE, code))).toBe(true); + expect(codes.some((code) => Object.hasOwn(LEGACY_LEGAL_NATURE, code))).toBe(false); + }); + + test("should partition the whole table across the five categories with includeLegacy", () => { + const codes = Object.keys(LEGAL_NATURE_CATEGORIES).flatMap((category) => + codesOf(category, { includeLegacy: true }), + ); + + expect(codes.length).toBe(100); + expect(new Set(codes).size).toBe(100); + expect(codes.every((code) => Object.hasOwn(LEGAL_NATURE, code))).toBe(true); + expect(codes.filter((code) => Object.hasOwn(LEGACY_LEGAL_NATURE, code)).length).toBe(8); + }); + + 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, fc.boolean(), (category, includeLegacy) => { + for (const legalNature of getLegalNaturesByCategory(category, { includeLegacy })) { + expect(legalNature).toEqual(getLegalNature(legalNature.code)); + expect(legalNature.code.startsWith(category)).toBe(true); + expect(legalNature.legacy).toBe(Object.hasOwn(LEGACY_LEGAL_NATURE, legalNature.code)); + } + }), + ); + }); + + test("should only ever add entries when includeLegacy is on", () => { + fc.assert( + fc.property(categoryCodes, (category) => { + const inForce = codesOf(category); + const withLegacy = codesOf(category, { includeLegacy: true }); + + expect(withLegacy.filter((code) => inForce.includes(code))).toEqual(inForce); + expect(withLegacy.length - inForce.length).toBe( + withLegacy.filter((code) => Object.hasOwn(LEGACY_LEGAL_NATURE, code)).length, + ); + }), + ); + }); + + 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, fc.boolean(), (category, includeLegacy) => { + expect(getLegalNaturesByCategory(Number(category), { includeLegacy })).toEqual( + getLegalNaturesByCategory(category, { includeLegacy }), + ); + }), + ); + }); + + test("should never throw, whatever it is given", () => { + expectNeverThrows(getLegalNaturesByCategory, anyValue); + }); + }); +}); + +describe("getLegalNaturesByCategory includeLegacy truthiness", () => { + test("should read includeLegacy for truthiness, like pad", () => { + // @ts-expect-error: intentionally invalid input + expect(getLegalNaturesByCategory("2", { includeLegacy: 1 }).length).toBe(33); + // @ts-expect-error: intentionally invalid input + expect(getLegalNaturesByCategory("2", { includeLegacy: 0 }).length).toBe(30); + }); +}); + +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(); + }); + + test("should take the listing options as an optional second parameter", () => { + expectTypeOf(getLegalNaturesByCategory) + .parameter(1) + .toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + boolean | undefined + >(); + }); +}); 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 000000000..9f49ce07b --- /dev/null +++ b/src/get-legal-natures-by-category/get-legal-natures-by-category.ts @@ -0,0 +1,81 @@ +import { LEGAL_NATURE_CATEGORIES } from "../_internals/constants/legal-nature-categories"; +import { isLegacyLegalNature } from "../_internals/is-legacy-legal-nature/is-legacy-legal-nature"; +import { buildLegalNature, type LegalNature } from "../get-legal-nature/get-legal-nature"; +import { LEGAL_NATURE } from "../is-valid-legal-nature/constants"; + +/** + * The options `getLegalNaturesByCategory` accepts, saying whether the legacy codes of the category + * are listed too. + */ +export type GetLegalNaturesByCategoryOptions = { + /** + * Whether the codes of the category that a past revision of the CONCLA table retired are listed + * alongside the ones in force (default: `false`). + */ + includeLegacy?: boolean; +}; + +/** + * 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. + * + * Only the codes in force are listed by default. Pass `{ includeLegacy: true }` to add the ones a + * past revision of the table retired (2076, 2100 and 2208 in category 2, 3042, 3050, 3093 and 3123 + * in category 3, 5002 in category 5), which come back with `legacy: true` and the `currentCode` + * they correspond to today. 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. + * @param {GetLegalNaturesByCategoryOptions} [options] - Optional listing options. + * @param {boolean} [options.includeLegacy] - Whether to add the retired codes. Defaults to `false`. + * @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" }, + * // legacy: false, + * // } + * getLegalNaturesByCategory(4).length; // 6 + * getLegalNaturesByCategory("2").length; // 30 + * getLegalNaturesByCategory("2", { includeLegacy: true }).length; // 33 + * getLegalNaturesByCategory("9"); // [] + * ``` + */ +export const getLegalNaturesByCategory = ( + category: string | number, + options?: GetLegalNaturesByCategoryOptions, +): LegalNature[] => { + if (typeof category !== "string" && typeof category !== "number") return []; + + const categoryCode = String(category); + + if (!Object.hasOwn(LEGAL_NATURE_CATEGORIES, categoryCode)) return []; + + const includeLegacy = Boolean(options?.includeLegacy); + const legalNatures: LegalNature[] = []; + + for (const [code, description] of Object.entries(LEGAL_NATURE)) { + if (!code.startsWith(categoryCode)) continue; + if (!includeLegacy && isLegacyLegalNature(code)) continue; + + legalNatures.push(buildLegalNature(code, description)); + } + + 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 7cf617ac5..3ccc8b799 100644 --- a/src/get-legal-natures/get-legal-natures.test.ts +++ b/src/get-legal-natures/get-legal-natures.test.ts @@ -2,10 +2,12 @@ import * as fc from "fast-check"; import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; import { getLegalNature } from "../get-legal-nature/get-legal-nature"; +import { LEGACY_LEGAL_NATURE, LEGAL_NATURE } from "../is-valid-legal-nature/constants"; import { isValidLegalNature } from "../is-valid-legal-nature/is-valid-legal-nature"; -import { getLegalNatures } from "./get-legal-natures"; +import { type GetLegalNaturesParams, getLegalNatures } from "./get-legal-natures"; -const knownCode = (): fc.Arbitrary => fc.constantFrom(...Object.keys(getLegalNatures())); +const knownCode = (): fc.Arbitrary => + fc.constantFrom(...Object.keys(getLegalNatures({ includeLegacy: true }))); describe("getLegalNatures", () => { it("should return legal nature entries", () => { @@ -18,6 +20,29 @@ describe("getLegalNatures", () => { expect(getLegalNatures()["3999"]).toBe("Associação Privada"); }); + it("should list only the 92 codes in force by default", () => { + const legalNatures = getLegalNatures(); + + expect(Object.keys(legalNatures).length).toBe(92); + expect(legalNatures["2208"]).toBeUndefined(); + expect(legalNatures["5002"]).toBeUndefined(); + }); + + it("should add the 8 retired codes with includeLegacy", () => { + const legalNatures = getLegalNatures({ includeLegacy: true }); + + expect(Object.keys(legalNatures).length).toBe(100); + expect(legalNatures["2208"]).toBe("Entidade Binacional Itaipu"); + expect(legalNatures["5002"]).toBe( + "Organização Internacional e Outras Instituições Extraterritoriais", + ); + }); + + it("should leave the retired codes out for an explicit includeLegacy false", () => { + expect(Object.keys(getLegalNatures({ includeLegacy: false })).length).toBe(92); + expect(Object.keys(getLegalNatures({})).length).toBe(92); + }); + it("should return a copy, so mutating the result does not change the table", () => { const legalNatures = getLegalNatures(); legalNatures["2062"] = "changed"; @@ -25,17 +50,51 @@ describe("getLegalNatures", () => { expect(getLegalNatures()["2062"]).toBe("Sociedade Empresária Limitada"); }); + it("should return a copy of the legacy listing too", () => { + const legalNatures = getLegalNatures({ includeLegacy: true }); + legalNatures["2208"] = "changed"; + + expect(getLegalNatures({ includeLegacy: true })["2208"]).toBe("Entidade Binacional Itaipu"); + }); + describe("properties", () => { const anyKey = fc.string(); test("should expose only codes its own validator and lookup accept", () => { fc.assert( fc.property(knownCode(), (code) => { - const entry = { code, description: getLegalNatures()[code] }; + const entry = { code, description: getLegalNatures({ includeLegacy: true })[code] }; expect(code).toMatch(/^\d{4}$/); expect(isValidLegalNature(code)).toBe(true); - expect(getLegalNature(code)).toEqual(entry); + expect(getLegalNature(code)).toMatchObject(entry); + }), + ); + }); + + test("should list a code by default exactly when it is not a legacy one", () => { + fc.assert( + fc.property(knownCode(), (code) => { + expect(Object.hasOwn(getLegalNatures(), code)).toBe( + !Object.hasOwn(LEGACY_LEGAL_NATURE, code), + ); + expect(Object.hasOwn(getLegalNatures({ includeLegacy: true }), code)).toBe(true); + }), + ); + }); + + const includeLegacyValues = fc.option(fc.boolean(), { nil: undefined }); + + test("should always describe a code of the table, whatever includeLegacy is", () => { + fc.assert( + fc.property(includeLegacyValues, (includeLegacy) => { + const legalNatures = getLegalNatures({ includeLegacy }); + const entries = Object.entries(legalNatures); + + expect(entries.length).toBe(includeLegacy === true ? 100 : 92); + expect(entries.every(([code, description]) => LEGAL_NATURE[code] === description)).toBe( + true, + ); }), ); }); @@ -55,9 +114,22 @@ describe("getLegalNatures", () => { }); }); +describe("getLegalNatures includeLegacy truthiness", () => { + test("should read includeLegacy for truthiness, like pad", () => { + // @ts-expect-error: intentionally invalid input + expect(Object.keys(getLegalNatures({ includeLegacy: 1 })).length).toBe(100); + // @ts-expect-error: intentionally invalid input + expect(Object.keys(getLegalNatures({ includeLegacy: 0 })).length).toBe(92); + }); +}); + describe("getLegalNatures types", () => { - test("should take no parameters and return a record of strings", () => { - expectTypeOf(getLegalNatures).parameters.toEqualTypeOf<[]>(); + test("should take optional listing options and return a record of strings", () => { + expectTypeOf(getLegalNatures).parameter(0).toEqualTypeOf(); expectTypeOf(getLegalNatures).returns.toEqualTypeOf>(); }); + + test("should type includeLegacy as an optional boolean", () => { + expectTypeOf().toEqualTypeOf(); + }); }); diff --git a/src/get-legal-natures/get-legal-natures.ts b/src/get-legal-natures/get-legal-natures.ts index a98b3500c..6ba9f9ea1 100644 --- a/src/get-legal-natures/get-legal-natures.ts +++ b/src/get-legal-natures/get-legal-natures.ts @@ -1,16 +1,51 @@ +import { isLegacyLegalNature } from "../_internals/is-legacy-legal-nature/is-legacy-legal-nature"; import { LEGAL_NATURE } from "../is-valid-legal-nature/constants"; +/** The object form `getLegalNatures` accepts, saying whether the legacy codes are listed too. */ +export type GetLegalNaturesParams = { + /** + * Whether the 8 codes a past revision of the CONCLA table retired are listed alongside the 92 + * in force (default: `false`). + */ + includeLegacy?: boolean; +}; + /** * Returns every Brazilian legal nature (natureza jurídica) published by the CONCLA. * + * Only the 92 codes of the Tabela de Natureza Jurídica 2021, the ones in force, are listed by + * default. Pass `{ includeLegacy: true }` to add the 8 a past revision of the table retired, which + * `isValidLegalNature` keeps accepting and `getLegalNature` keeps looking up because they still + * appear in records filed while they were in force. + * + * @param {GetLegalNaturesParams} [params] - Optional listing options. + * @param {boolean} [params.includeLegacy] - Whether to add the retired codes. Defaults to `false`. * @returns {Record} A fresh object mapping each 4 digit code to its description. * * @example * ```typescript * getLegalNatures()["2062"]; // "Sociedade Empresária Limitada" + * Object.keys(getLegalNatures()).length; // 92 + * getLegalNatures()["2208"]; // undefined (retired by a past revision) + * getLegalNatures({ includeLegacy: true })["2208"]; // "Entidade Binacional Itaipu" + * Object.keys(getLegalNatures({ includeLegacy: true })).length; // 100 * ``` * + * 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 getLegalNatures = (): Record => ({ ...LEGAL_NATURE }); +export const getLegalNatures = (params?: GetLegalNaturesParams): Record => { + if (params?.includeLegacy ?? false) return { ...LEGAL_NATURE }; + + const legalNatures: Record = {}; + + for (const [code, description] of Object.entries(LEGAL_NATURE)) { + if (!isLegacyLegalNature(code)) legalNatures[code] = description; + } + + return legalNatures; +}; diff --git a/src/get-municipalities/get-municipalities.test.ts b/src/get-municipalities/get-municipalities.test.ts index 18f7af21c..3601c4521 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", @@ -119,6 +128,12 @@ describe("getMunicipalities", () => { ); }); + test("should return an empty list for a state code that is not a string, an object without a primitive value included", () => { + expect(getMunicipalities(Object.create(null) as never)).toEqual([]); + expect(getMunicipalities(["SP"] as never)).toEqual([]); + expect(getMunicipalities(35 as never)).toEqual([]); + }); + test("should list, for every state, the same names and order as getCities", () => { fc.assert( fc.property(stateCodeArbitrary, (stateCode) => { diff --git a/src/get-municipalities/get-municipalities.ts b/src/get-municipalities/get-municipalities.ts index 821321087..f77f25a94 100644 --- a/src/get-municipalities/get-municipalities.ts +++ b/src/get-municipalities/get-municipalities.ts @@ -1,6 +1,8 @@ import { DATA as CITIES_DATA, type Municipality } from "../_internals/constants/cities"; -import { type StateCode } from "../_internals/constants/states"; -import { getStates } from "../get-states/get-states"; +import { DATA, type StateCode } from "../_internals/constants/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 +12,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,20 +34,20 @@ 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 */ export const getMunicipalities = (stateCode?: StateCode): Municipality[] => { if (stateCode === undefined) { - return getStates() - .flatMap((state) => buildMunicipalities(state.code)) - .sort((a, b) => a.name.localeCompare(b.name, "pt-BR")); + return DATA.flatMap((state) => buildMunicipalities(state.code)).sort((a, b) => + a.name.localeCompare(b.name, "pt-BR"), + ); } - const state = getStates().find((candidate) => candidate.code === stateCode); - - if (!state) return []; + if (typeof stateCode !== "string" || !Object.hasOwn(CITIES_DATA, stateCode)) return []; - return buildMunicipalities(state.code); + return buildMunicipalities(stateCode); }; 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 0fe0a02f2..a8569b44a 100644 --- a/src/get-municipality-by-code/get-municipality-by-code.ts +++ b/src/get-municipality-by-code/get-municipality-by-code.ts @@ -1,7 +1,9 @@ import { DATA as CITIES_DATA, type Municipality } from "../_internals/constants/cities"; +import { DATA } from "../_internals/constants/states"; 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. @@ -29,12 +31,12 @@ export const getMunicipalityByCode = (code: string | number): Municipality | nul // Every real municipality code is exactly 7 digits, so a `digits` of the wrong length simply // finds no match in the loop below; there is no need to pre-validate its length here first. - for (const state of getStates()) { - const match = CITIES_DATA[state.code].find( + for (const { code: stateCode } of DATA) { + const match = CITIES_DATA[stateCode].find( ([, municipalityCode]) => municipalityCode === digits, ); - if (match) return { code: digits, name: match[0], stateCode: state.code }; + if (match) return { code: digits, name: match[0], stateCode }; } return null; diff --git a/src/get-municipality/get-municipality.test.ts b/src/get-municipality/get-municipality.test.ts index 6f969b19f..f5afb47de 100644 --- a/src/get-municipality/get-municipality.test.ts +++ b/src/get-municipality/get-municipality.test.ts @@ -1,8 +1,8 @@ import { describe, expect, expectTypeOf, it } from "../_internals/test/runtime"; import { - type GetMunicipalityByCodeOptions, - type GetMunicipalityByNameOptions, - type GetMunicipalityOptions, + type GetMunicipalityByCodeParams, + type GetMunicipalityByNameParams, + type GetMunicipalityParams, getMunicipality, } from "./get-municipality"; @@ -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 () => { @@ -150,7 +180,7 @@ describe("getMunicipality", () => { it("should return null for a non-string uf", async () => { // @ts-expect-error: intentionally invalid input - const options: GetMunicipalityByNameOptions = { municipalityName: "São Paulo", uf: null }; + const options: GetMunicipalityByNameParams = { municipalityName: "São Paulo", uf: null }; await expect(getMunicipality(options)).resolves.toBeNull(); }); @@ -187,19 +217,25 @@ describe("getMunicipality", () => { }); }); +const lookUpEither = (options: GetMunicipalityParams) => 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(); - expectTypeOf().toEqualTypeOf< - GetMunicipalityByCodeOptions | GetMunicipalityByNameOptions + it("should take a code or a name plus uf", () => { + expectTypeOf().toEqualTypeOf< + GetMunicipalityByCodeParams | GetMunicipalityByNameParams >(); - expectTypeOf().toEqualTypeOf<{ code: string | number }>(); - expectTypeOf().toEqualTypeOf<{ + expectTypeOf().toEqualTypeOf<{ code: string | number }>(); + expectTypeOf().toEqualTypeOf<{ 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: GetMunicipalityByCodeParams = { code: "3550308" }; + const byName: GetMunicipalityByNameParams = { 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 ff56f74c2..65e561685 100644 --- a/src/get-municipality/get-municipality.ts +++ b/src/get-municipality/get-municipality.ts @@ -1,17 +1,18 @@ import { DATA as CITIES_DATA } from "../_internals/constants/cities"; +import { type StateCode } from "../_internals/constants/states"; 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 = { +export type GetMunicipalityByCodeParams = { /** The 7 digit IBGE municipality code, as a string or a number. */ code: string | number; }; /** The `getMunicipality` query by municipality name and state code. */ -export type GetMunicipalityByNameOptions = { +export type GetMunicipalityByNameParams = { /** The municipality name, accents and casing ignored. */ municipalityName: string; /** The two letter state code the municipality belongs to, e.g. "SP". */ @@ -19,19 +20,37 @@ export type GetMunicipalityByNameOptions = { }; /** The two ways `getMunicipality` can be queried: by IBGE code, or by municipality name and state code. */ -export type GetMunicipalityOptions = GetMunicipalityByCodeOptions | GetMunicipalityByNameOptions; +export type GetMunicipalityParams = GetMunicipalityByCodeParams | GetMunicipalityByNameParams; -let codeIndex: Map | undefined; +/** + * The `getMunicipality` query by IBGE municipality code, the 2.3.0 name of + * `GetMunicipalityByCodeParams`. + * + * @deprecated Use `GetMunicipalityByCodeParams` instead. + */ +export type GetMunicipalityByCodeOptions = GetMunicipalityByCodeParams; -// 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(); +/** + * The `getMunicipality` query by municipality name and state code, the 2.3.0 name of + * `GetMunicipalityByNameParams`. + * + * @deprecated Use `GetMunicipalityByNameParams` instead. + */ +export type GetMunicipalityByNameOptions = GetMunicipalityByNameParams; + +/** + * The two ways `getMunicipality` can be queried, the 2.3.0 name of `GetMunicipalityParams`. + * + * @deprecated Use `GetMunicipalityParams` instead. + */ +export type GetMunicipalityOptions = GetMunicipalityParams; + +let codeIndex: Map | undefined; 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,42 +64,97 @@ 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 isStateCode = (value: string): value is StateCode => Object.hasOwn(CITIES_DATA, value); + const getMunicipalityCodeByName = ({ municipalityName, uf, -}: GetMunicipalityByNameOptions): string | null => { +}: GetMunicipalityByNameParams): string | null => { if (typeof uf !== "string") return null; const normalizedUf = uf.trim().toUpperCase(); // Every real state code is exactly 2 uppercase letters, so a malformed `normalizedUf` (wrong // length, digits, ...) simply finds no match below; there is no need to pre-validate its shape. - const stateEntry = Object.entries(CITIES_DATA).find(([code]) => code === normalizedUf); - - if (!stateEntry) return null; + if (!isStateCode(normalizedUf)) 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 = CITIES_DATA[normalizedUf].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 {GetMunicipalityByCodeParams} 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. + * + * @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: GetMunicipalityByCodeParams, +): 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`. * - * 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`. + * @param {GetMunicipalityByNameParams} options - The `{ municipalityName, uf }` query. + * @returns {Promise} The 7 digit IBGE code, or null when the state code or the + * municipality is unknown. * - * @param {GetMunicipalityOptions} options - Either `{ code }` or `{ municipalityName, uf }`. + * @example + * ```typescript + * await getMunicipality({ municipalityName: "sao paulo", uf: "sp" }); // "3550308" + * ``` + * + * @see Official: https://servicodados.ibge.gov.br/api/docs/localidades + */ +export function getMunicipality(options: GetMunicipalityByNameParams): 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 {GetMunicipalityParams} options - Either `{ code }` or `{ municipalityName, uf }`. * @returns {Promise<[string, string] | string | null>} The `[name, uf]` pair when looking up * by code, the IBGE code when looking up by name, or null when the municipality is unknown * (this includes `options` itself being missing or not an object, e.g. `null`, `undefined`, @@ -88,16 +162,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: GetMunicipalityParams) => getMunicipality(options); + * + * await lookUp({ code: "3550308" }); // ["São Paulo", "SP"] * ``` * * @see Official: https://servicodados.ibge.gov.br/api/docs/localidades */ -export const getMunicipality = ( - options: GetMunicipalityOptions, -): Promise<[string, string] | null | string> => { +export function getMunicipality( + options: GetMunicipalityParams, +): Promise<[string, string] | string | null>; + +export function getMunicipality( + options: GetMunicipalityParams, +): Promise<[string, string] | string | null> { if (isNullish(options) || typeof options !== "object" || Array.isArray(options)) { return Promise.resolve(null); } @@ -107,4 +185,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 62% rename from src/parse-nfe-key/constants.ts rename to src/get-nfe-key-info/constants.ts index 95a3726da..33c8298b1 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. */ -export type ValidModel = (typeof VALID_MODELS)[number]; +/** One of the `mod` values `getNfeKeyInfo` supports. */ +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 000000000..9c385dfcf --- /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 000000000..8995b7acc --- /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 10a50ae43..8595a78d3 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 74% rename from src/parse-pix-key/parse-pix-key.ts rename to src/get-pix-key-info/get-pix-key-info.ts index 5917d6c0f..61e26fe14 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,32 +67,31 @@ 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(); - // Stryker disable next-line ConditionalExpression: an empty trimmed value never matches the EVP regex, never contains "@", is never a valid CNPJ, matches neither the CPF nor the phone syntax, so every branch below already falls through to null on its own - if (!trimmed) return null; - if (EVP_REGEX.test(trimmed)) return { type: "evp", value: trimmed.toLowerCase() }; if (trimmed.includes("@")) { 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 73c4c1b7a..79b1765f3 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 85% rename from src/parse-pix-payload/parse-pix-payload.ts rename to src/get-pix-payload-info/get-pix-payload-info.ts index 477c0d5a6..8cca8d94b 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. */ @@ -63,9 +63,6 @@ export type PixPayload = { pointOfInitiation: PixPointOfInitiation; }; -// Stryker disable next-line Regex: this is only ever tested against `checksum`, a slice of exactly PIX_CRC_LENGTH (4) characters, so dropping either anchor cannot change whether it matches -const CRC_VALUE_REGEX = /^[0-9a-f]{4}$/i; - const AMOUNT_REGEX = /^\d+(?:\.\d{1,2})?$/; const WITHDRAWAL_FACILITATOR_REGEX = /^\d{8}$/; @@ -95,9 +92,8 @@ const isValidCrc = (payload: string): boolean => { if (payload.slice(-CRC_TAG_LENGTH, -PIX_CRC_LENGTH) !== PIX_CRC_TAG) return false; - // Stryker disable next-line ConditionalExpression: a checksum that fails this hex check can never equal crc16Ccitt's always-hex output, so the final comparison below already rejects it on its own - if (!CRC_VALUE_REGEX.test(checksum)) return false; - + // A checksum that is not four uppercase hexadecimal digits can never equal crc16Ccitt's + // always-hexadecimal output, so the comparison below turns it down on its own. return crc16Ccitt(payload.slice(0, -PIX_CRC_LENGTH)) === checksum.toUpperCase(); }; @@ -146,6 +142,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 +175,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 +213,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 +238,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 +264,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 42d30a4ee..266b41d9e 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 01c34c1d6..f84096a7e 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 9c746f920..28fc41a69 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 6bf606317..a9eda4ac6 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 3125fdc5e..460e2876a 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 90bdf88d9..99291586c 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 c9c3d5b2c..5e31a910a 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 f67d90aa3..9e44e849e 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,66 +1,79 @@ 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 GenerateBoletoParams, + type GenerateCnpjParams, type GenerateLicensePlateFormat, type GeneratePhoneType, type GeneratePixPayloadParams, type GenerateProcessoJuridicoOptions, + type GenerateProcessoJuridicoParams, type GetAddressInfoByCepOptions, type GetBoletoInfoOptions, type GetCepInfoByAddressOptions, + type GetCepInfoByAddressParams, type GetHolidaysOptions, + type GetHolidaysParams, + type GetLegalNaturesByCategoryOptions, + type GetLegalNaturesParams, type GetMunicipalityByCodeOptions, + type GetMunicipalityByCodeParams, type GetMunicipalityByNameOptions, + type GetMunicipalityByNameParams, type GetMunicipalityOptions, + type GetMunicipalityParams, type Holiday, type HolidayType, - type Iban, - type IsBusinessDayOptions, + type IbanInfo, type IsHolidayOptions, + type IsHolidayParams, type IsValidBankAccountOptions, type IsValidBankAccountParams, type IsValidCertidaoOptions, type IsValidCnpjOptions, type IsValidCstOptions, + type IsValidIeParams, type IsValidMobilePhoneOptions, type IsValidPhoneOptions, type IsValidPixKeyOptions, - type IsValidRegistroProfissionalOptions, + type IsValidRegistroProfissionalParams, type LegalNature, + type LegalNatureCategory, type LicensePlateFormat, type Municipality, - type NfeKey, + type NfeKeyInfo, type NfeKeyModel, type NumberToWordsGender, type ParseCnpjOptions, @@ -68,15 +81,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 +146,7 @@ const PUBLIC = [ "generatePis", "generatePixPayload", "generateProcessoJuridico", + "generateRenavam", "generateVoterId", "getAddressInfoByCep", "getAreaCodeInfo", @@ -144,16 +157,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 +223,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,67 +277,80 @@ 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; + GenerateBoletoParams: GenerateBoletoParams; + GenerateCnpjParams: GenerateCnpjParams; GenerateLicensePlateFormat: GenerateLicensePlateFormat; GeneratePhoneType: GeneratePhoneType; GeneratePixPayloadParams: GeneratePixPayloadParams; GenerateProcessoJuridicoOptions: GenerateProcessoJuridicoOptions; + GenerateProcessoJuridicoParams: GenerateProcessoJuridicoParams; GetAddressInfoByCepOptions: GetAddressInfoByCepOptions; GetBoletoInfoOptions: GetBoletoInfoOptions; GetCepInfoByAddressOptions: GetCepInfoByAddressOptions; + GetCepInfoByAddressParams: GetCepInfoByAddressParams; GetHolidaysOptions: GetHolidaysOptions; + GetHolidaysParams: GetHolidaysParams; + GetLegalNaturesByCategoryOptions: GetLegalNaturesByCategoryOptions; + GetLegalNaturesParams: GetLegalNaturesParams; GetMunicipalityByCodeOptions: GetMunicipalityByCodeOptions; + GetMunicipalityByCodeParams: GetMunicipalityByCodeParams; GetMunicipalityByNameOptions: GetMunicipalityByNameOptions; + GetMunicipalityByNameParams: GetMunicipalityByNameParams; GetMunicipalityOptions: GetMunicipalityOptions; + GetMunicipalityParams: GetMunicipalityParams; Holiday: Holiday; HolidayType: HolidayType; - Iban: Iban; - IsBusinessDayOptions: IsBusinessDayOptions; + IbanInfo: IbanInfo; IsHolidayOptions: IsHolidayOptions; + IsHolidayParams: IsHolidayParams; IsValidBankAccountOptions: IsValidBankAccountOptions; IsValidBankAccountParams: IsValidBankAccountParams; IsValidCertidaoOptions: IsValidCertidaoOptions; IsValidCnpjOptions: IsValidCnpjOptions; IsValidCstOptions: IsValidCstOptions; + IsValidIeParams: IsValidIeParams; IsValidMobilePhoneOptions: IsValidMobilePhoneOptions; IsValidPhoneOptions: IsValidPhoneOptions; IsValidPixKeyOptions: IsValidPixKeyOptions; - IsValidRegistroProfissionalOptions: IsValidRegistroProfissionalOptions; + IsValidRegistroProfissionalParams: IsValidRegistroProfissionalParams; LegalNature: LegalNature; + LegalNatureCategory: LegalNatureCategory; LicensePlateFormat: LicensePlateFormat; Municipality: Municipality; - NfeKey: NfeKey; + NfeKeyInfo: NfeKeyInfo; NfeKeyModel: NfeKeyModel; NumberToWordsGender: NumberToWordsGender; ParseCnpjOptions: ParseCnpjOptions; @@ -319,15 +358,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 1729cb118..ea1666f62 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"; @@ -46,10 +54,10 @@ export { formatProcessoJuridico, } from "./format-processo-juridico/format-processo-juridico"; export { formatVoterId } from "./format-voter-id/format-voter-id"; -export { type GenerateBoletoOptions, generateBoleto } from "./generate-boleto/generate-boleto"; +export { type GenerateBoletoParams, 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 GenerateCnpjParams, generateCnpj } from "./generate-cnpj/generate-cnpj"; export { generateCpf } from "./generate-cpf/generate-cpf"; export { generateLegalNature } from "./generate-legal-nature/generate-legal-nature"; export { @@ -64,9 +72,10 @@ export { generatePixPayload, } from "./generate-pix-payload/generate-pix-payload"; export { - type GenerateProcessoJuridicoOptions, + type GenerateProcessoJuridicoParams, 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, @@ -93,10 +102,15 @@ export { type CepAddressInfo, GetCepInfoByAddressError, GetCepInfoByAddressNotFoundError, - type GetCepInfoByAddressOptions, + type GetCepInfoByAddressParams, 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"; @@ -105,30 +119,54 @@ export { type LicensePlateFormat, } from "./get-format-license-plate/get-format-license-plate"; export { - type GetHolidaysOptions, + type GetHolidaysParams, type Holiday, type HolidayType, getHolidays, } from "./get-holidays/get-holidays"; -export { type LegalNature, getLegalNature } from "./get-legal-nature/get-legal-nature"; -export { getLegalNatures } from "./get-legal-natures/get-legal-natures"; +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 { type GetLegalNaturesParams, getLegalNatures } from "./get-legal-natures/get-legal-natures"; +export { + type GetLegalNaturesByCategoryOptions, + getLegalNaturesByCategory, +} from "./get-legal-natures-by-category/get-legal-natures-by-category"; export { getMunicipalities } from "./get-municipalities/get-municipalities"; export { - type GetMunicipalityByCodeOptions, - type GetMunicipalityByNameOptions, - type GetMunicipalityOptions, + type GetMunicipalityByCodeParams, + type GetMunicipalityByNameParams, + type GetMunicipalityParams, 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 IsHolidayOptions, isHoliday } from "./is-holiday/is-holiday"; +export { type BusinessDayOptions, isBusinessDay } from "./is-business-day/is-business-day"; +export { type IsHolidayParams, isHoliday } from "./is-holiday/is-holiday"; export { - type IsValidBankAccountOptions, + type IsValidBankAccountParams, isValidBankAccount, } from "./is-valid-bank-account/is-valid-bank-account"; export { isValidBoleto } from "./is-valid-boleto/is-valid-boleto"; @@ -152,7 +190,7 @@ export { isValidCsosn } from "./is-valid-csosn/is-valid-csosn"; export { type IsValidCstOptions, isValidCst } from "./is-valid-cst/is-valid-cst"; export { isValidEmail } from "./is-valid-email/is-valid-email"; export { isValidIban } from "./is-valid-iban/is-valid-iban"; -export { isValidIe } from "./is-valid-ie/is-valid-ie"; +export { type IsValidIeParams, isValidIe } from "./is-valid-ie/is-valid-ie"; export { isValidLandlinePhone } from "./is-valid-landline-phone/is-valid-landline-phone"; export { isValidLegalNature } from "./is-valid-legal-nature/is-valid-legal-nature"; export { isValidLicensePlate } from "./is-valid-license-plate/is-valid-license-plate"; @@ -175,7 +213,7 @@ export { isValidPixPayload } from "./is-valid-pix-payload/is-valid-pix-payload"; export { isValidProcessoJuridico } from "./is-valid-processo-juridico/is-valid-processo-juridico"; export type { RegistroProfissionalCouncil } from "./is-valid-registro-profissional/constants"; export { - type IsValidRegistroProfissionalOptions, + type IsValidRegistroProfissionalParams, isValidRegistroProfissional, } from "./is-valid-registro-profissional/is-valid-registro-profissional"; export { isValidRenavam } from "./is-valid-renavam/is-valid-renavam"; @@ -183,53 +221,149 @@ 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 parameters of `generateProcessoJuridico`, the 2.3.0 name of + * `GenerateProcessoJuridicoParams`. + * + * @deprecated Use `GenerateProcessoJuridicoParams` instead. + */ +export type { GenerateProcessoJuridicoOptions } from "./generate-processo-juridico/generate-processo-juridico"; +/** + * The address `getCepInfoByAddress` looks up, the 2.3.0 name of `GetCepInfoByAddressParams`. + * + * @deprecated Use `GetCepInfoByAddressParams` instead. + */ +export type { GetCepInfoByAddressOptions } from "./get-cep-info-by-address/get-cep-info-by-address"; +/** + * The object form `getHolidays` accepts, the 2.3.0 name of `GetHolidaysParams`. + * + * @deprecated Use `GetHolidaysParams` instead. + */ +export type { GetHolidaysOptions } from "./get-holidays/get-holidays"; +/** + * The `getMunicipality` query by IBGE municipality code, the 2.3.0 name of + * `GetMunicipalityByCodeParams`. + * + * @deprecated Use `GetMunicipalityByCodeParams` instead. + */ +export type { GetMunicipalityByCodeOptions } from "./get-municipality/get-municipality"; +/** + * The `getMunicipality` query by municipality name and state code, the 2.3.0 name of + * `GetMunicipalityByNameParams`. + * + * @deprecated Use `GetMunicipalityByNameParams` instead. + */ +export type { GetMunicipalityByNameOptions } from "./get-municipality/get-municipality"; +/** + * The two ways `getMunicipality` can be queried, the 2.3.0 name of `GetMunicipalityParams`. + * + * @deprecated Use `GetMunicipalityParams` instead. + */ +export type { GetMunicipalityOptions } from "./get-municipality/get-municipality"; +/** + * The parameters `isHoliday` takes, the 2.3.0 name of `IsHolidayParams`. + * + * @deprecated Use `IsHolidayParams` instead. + */ +export type { IsHolidayOptions } from "./is-holiday/is-holiday"; /** * The bank account `isValidBankAccount` checks: the bank, the agency and the account with its * check digit. * - * @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"; + * Kept from 2.3.0: the name violates the naming rule, since this object is the only argument + * `isValidBankAccount` takes, but it shipped in 2.3.0 as the canonical name. + * + * @deprecated Use `IsValidBankAccountParams` instead. + */ +export type { IsValidBankAccountOptions } from "./is-valid-bank-account/is-valid-bank-account"; +// 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 138174b24..314b179c2 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 23ee24a50..a918a57d3 100644 --- a/src/is-business-day/is-business-day.ts +++ b/src/is-business-day/is-business-day.ts @@ -1,9 +1,15 @@ -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 { isValidDate } from "../_internals/is-valid-date/is-valid-date"; 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 +33,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 +74,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 => { - if (!(value instanceof Date) || Number.isNaN(value.getTime())) return false; +export const isBusinessDay = (value: Date, options?: BusinessDayOptions): boolean => { + if (!isValidDate(value)) 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 f2c53ed40..855bfacc6 100644 --- a/src/is-holiday/is-holiday.test.ts +++ b/src/is-holiday/is-holiday.test.ts @@ -5,12 +5,19 @@ import { holidayYears, monthDays, monthIndexes, stateCodes } from "../_internals import { expectNeverThrows } from "../_internals/test/properties"; import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; import { getHolidays, type Holiday } from "../get-holidays/get-holidays"; -import { isHoliday, type IsHolidayOptions } from "./is-holiday"; +import { isHoliday, type IsHolidayParams } from "./is-holiday"; 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); @@ -32,6 +39,18 @@ describe("isHoliday", () => { expect(isHoliday({ targetDate: new Date(2024, 6, 9) })).toBe(false); }); + it("should return false for RN's 7 August: Lei RN nº 7.831/2000 makes the Dia do Rio Grande do Norte a commemorative date, not a feriado, while 7 September stays true everywhere as the national Independência do Brasil", () => { + expect(isHoliday({ targetDate: new Date(2026, 7, 7), stateCode: "RN" })).toBe(false); + expect(isHoliday({ targetDate: new Date(2026, 8, 7), stateCode: "RN" })).toBe(true); + expect(isHoliday({ targetDate: new Date(2026, 8, 7) })).toBe(true); + expect(isHoliday({ targetDate: new Date(2026, 9, 3), stateCode: "RN" })).toBe(true); + }); + + it("should return false for RO's 18 June, the Dia dos Evangélicos of the Lei RO nº 1.026/2001 that STF ADI 3940 voided, while RO's 4 January data magna stays true", () => { + expect(isHoliday({ targetDate: new Date(2019, 5, 18), stateCode: "RO" })).toBe(false); + expect(isHoliday({ targetDate: new Date(2019, 0, 4), stateCode: "RO" })).toBe(true); + }); + it("should return false when called without arguments", () => { expect(isHoliday()).toBe(false); }); @@ -67,6 +86,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,16 +130,16 @@ 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); }); }); }); describe("isHoliday types", () => { test("should take an options object and return a boolean", () => { - expectTypeOf(isHoliday).parameter(0).toEqualTypeOf(); - expectTypeOf().toEqualTypeOf<{ targetDate: Date; stateCode?: StateCode }>(); + expectTypeOf(isHoliday).parameter(0).toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<{ targetDate: Date; stateCode?: StateCode }>(); expectTypeOf(isHoliday).returns.toEqualTypeOf(); }); }); diff --git a/src/is-holiday/is-holiday.ts b/src/is-holiday/is-holiday.ts index b8f5b2786..1883f76d0 100644 --- a/src/is-holiday/is-holiday.ts +++ b/src/is-holiday/is-holiday.ts @@ -1,15 +1,25 @@ import { type StateCode } from "../_internals/constants/states"; import { isNullish } from "../_internals/is-nullish/is-nullish"; +import { isValidDate } from "../_internals/is-valid-date/is-valid-date"; import { getHolidays } from "../get-holidays/get-holidays"; -/** The options `isHoliday` takes: the date to check and, optionally, the state whose holidays also count. */ -export type IsHolidayOptions = { +export type { StateCode } from "../_internals/constants/states"; + +/** The parameters `isHoliday` takes: the date to check and, optionally, the state whose holidays also count. */ +export type IsHolidayParams = { /** The date to check, read by its local calendar day. */ targetDate: Date; /** Two letter state code whose state holidays are also considered (default: national holidays only). */ stateCode?: StateCode; }; +/** + * The parameters `isHoliday` takes, the 2.3.0 name of `IsHolidayParams`. + * + * @deprecated Use `IsHolidayParams` instead. + */ +export type IsHolidayOptions = IsHolidayParams; + /** * Checks whether a given date is a Brazilian holiday. * @@ -20,10 +30,24 @@ 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: * - * @param {IsHolidayOptions} [options] - Options for the check. + * - 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 {IsHolidayParams} [options] - Options for the check. * @param {Date} options.targetDate - The date to check. * @param {StateCode} [options.stateCode] - Optional Brazilian state code to also consider state holidays. * @returns {boolean} True when the date is a holiday, false otherwise. Bad input also returns @@ -40,27 +64,33 @@ 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 => { +export const isHoliday = (options?: IsHolidayParams): boolean => { if (isNullish(options) || typeof options !== "object") { return false; } const { targetDate, stateCode } = options; - if (!(targetDate instanceof Date) || Number.isNaN(targetDate.getTime())) { - return false; - } + if (!isValidDate(targetDate)) return false; if (stateCode !== undefined && typeof stateCode !== "string") { return false; diff --git a/src/is-valid-bank-account/is-valid-bank-account.test.ts b/src/is-valid-bank-account/is-valid-bank-account.test.ts index c801ad9ac..c9c14d97d 100644 --- a/src/is-valid-bank-account/is-valid-bank-account.test.ts +++ b/src/is-valid-bank-account/is-valid-bank-account.test.ts @@ -4,9 +4,9 @@ import { BANKS } from "../_internals/constants/banks"; import { bench, describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; import { COMPE_CODES, STRUCTURE_ONLY_BANK_CODES } from "./constants"; import { - ALGORITHM_BANK_CODES, isValidBankAccount, type IsValidBankAccountOptions, + type IsValidBankAccountParams, } from "./is-valid-bank-account"; const BANCO_DO_BRASIL_AGENCY_TOO_LONG_PARAMS = { @@ -22,6 +22,9 @@ const LISTED_CODES = new Set( ), ); +/** The bank codes `isValidBankAccount` validates with a published check digit algorithm. */ +const ALGORITHM_BANK_CODES = ["001", "033", "041", "104", "237", "260", "341", "399", "745"]; + const CHECK_CHARACTERS = [...Array.from({ length: 10 }, (_, digit) => String(digit)), "X", "P"]; describe("isValidBankAccount", () => { @@ -1209,18 +1212,6 @@ describe("isValidBankAccount", () => { }); test("should list every bank code with a published algorithm in COMPE_CODES and in BANKS", () => { - expect([...ALGORITHM_BANK_CODES].sort()).toStrictEqual([ - "001", - "033", - "041", - "104", - "237", - "260", - "341", - "399", - "745", - ]); - const missing = ALGORITHM_BANK_CODES.filter( (bankCode) => !LISTED_CODES.has(bankCode) || !BANKS.some((bank) => bank.code === bankCode), ); @@ -1688,14 +1679,19 @@ describe("isValidBankAccount", () => { }); describe("isValidBankAccount types", () => { - test("should take required bank account options and return a boolean", () => { - expectTypeOf(isValidBankAccount).parameter(0).toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); + test("should take required bank account params and return a boolean", () => { + expectTypeOf(isValidBankAccount).parameter(0).toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); expectTypeOf(isValidBankAccount).returns.toEqualTypeOf(); }); + + test("should keep the 2.3.0 name as an alias of the params type", () => { + // oxlint-disable-next-line typescript/no-deprecated -- the deprecated 2.3.0 alias is the type under test + expectTypeOf().toEqualTypeOf(); + }); }); describe("isValidBankAccount benchmarks", () => { 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 bbfd2c8bd..030aee945 100644 --- a/src/is-valid-bank-account/is-valid-bank-account.ts +++ b/src/is-valid-bank-account/is-valid-bank-account.ts @@ -16,14 +16,18 @@ import { } from "./constants"; /** The bank account `isValidBankAccount` checks: the bank, the agency and the account with its check digit. */ -export type IsValidBankAccountOptions = { +export type IsValidBankAccountParams = { /** Three digit bank code (COMPE), e.g. "001" for Banco do Brasil. */ bankCode: string; /** Agency number, digits only, without its own check digit. */ 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; }; @@ -31,9 +35,13 @@ export type IsValidBankAccountOptions = { * The bank account `isValidBankAccount` checks: the bank, the agency and the account with its * check digit. * - * @deprecated Use `IsValidBankAccountOptions` instead. + * Kept from 2.3.0: the name violates the naming rule (`Options` is the type of a second, + * usually optional, argument, and this object is the only argument `isValidBankAccount` takes), + * but it shipped in 2.3.0 as the canonical name, so it stays as an alias until v3. + * + * @deprecated Use `IsValidBankAccountParams` instead. */ -export type IsValidBankAccountParams = IsValidBankAccountOptions; +export type IsValidBankAccountOptions = IsValidBankAccountParams; type BankAccountDigits = (agency: string, account: string) => string[]; @@ -181,9 +189,6 @@ const BANK_RULES: Record = { }, }; -/** The bank codes validated by a published check digit algorithm. */ -export const ALGORITHM_BANK_CODES = Object.keys(BANK_RULES); - const STRUCTURE_ONLY_RULE: BankAccountRule = { minAgencyLength: 1, maxAgencyLength: 5, @@ -260,7 +265,7 @@ const sanitizeCheckDigit = (value: string): string => * * Every other bank of the list falls back to a generic modulus 10 and modulus 11 check. * - * @param {IsValidBankAccountOptions} params - The bank account parameters. + * @param {IsValidBankAccountParams} params - The bank account parameters. * @param {string} params.bankCode - The bank code (3 digits), as published by Banco Central. * @param {string} params.agency - The agency number (1-5 digits). * @param {string} params.account - The account number (1-13 digits). For Caixa, operação + conta. @@ -286,17 +291,13 @@ const sanitizeCheckDigit = (value: string): string => * @see Based on: https://github.com/luizalabs/heimdall/blob/main/heimdall_valid_bank/calculate_number_account.py * @see Based on: https://github.com/Xerpa/bran_checker/tree/master/lib/banks */ -export const isValidBankAccount = (params: IsValidBankAccountOptions): boolean => { +export const isValidBankAccount = (params: IsValidBankAccountParams): boolean => { if (isNullish(params) || typeof params !== "object") return false; const { bankCode, agency, account, digit } = params; + // An empty field is left to the length checks below, which reject it once sanitized. if ( - // Stryker disable next-line ConditionalExpression,LogicalOperator: bankCode, agency, account and digit are typed as strings, so the only falsy value any of them can take is "", which the length checks below (once sanitized) reject on their own regardless of this chain. - !bankCode || - !agency || - !account || - !digit || typeof bankCode !== "string" || typeof agency !== "string" || typeof account !== "string" || diff --git a/src/is-valid-boleto/is-valid-boleto.test.ts b/src/is-valid-boleto/is-valid-boleto.test.ts index fc5892d1e..df85e9a79 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 56f148f82..af6e8596e 100644 --- a/src/is-valid-boleto/is-valid-boleto.ts +++ b/src/is-valid-boleto/is-valid-boleto.ts @@ -1,4 +1,3 @@ -import { ARRECADACAO_PRODUCT } from "../_internals/constants/arrecadacao"; import { BOLETO_LENGTH } from "../_internals/constants/boleto"; import { mod10 } from "../_internals/mod10/mod10"; import { mod11 } from "../_internals/mod11/mod11"; @@ -37,6 +36,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,20 +50,20 @@ 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; + if (parseArrecadacao(digits)) return true; if (digits.length !== BOLETO_LENGTH) return false; diff --git a/src/is-valid-caepf/constants.ts b/src/is-valid-caepf/constants.ts index d761533d9..66bef5776 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 887340c22..0335de0e2 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 f36797108..0dabe6c5b 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 1fb0f7f36..266d0b27c 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 676b56b3b..2064d8444 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 2850055a9..4d8d7bd30 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 f40e8e727..2f88fe50f 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-cep/is-valid-cep.ts b/src/is-valid-cep/is-valid-cep.ts index 8791ab1f1..ff4493464 100644 --- a/src/is-valid-cep/is-valid-cep.ts +++ b/src/is-valid-cep/is-valid-cep.ts @@ -1,4 +1,4 @@ -const SEPARATORS_REGEX = /[\s.-]/g; +import { SEPARATORS_REGEX } from "../_internals/constants/separators"; const CEP_REGEX = /^\d{8}$/; diff --git a/src/is-valid-certidao/is-valid-certidao.test.ts b/src/is-valid-certidao/is-valid-certidao.test.ts index 66652fe2f..d41d21f3c 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 f8ea9e548..24b910632 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 d04a0f4a6..9437da390 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,13 +43,15 @@ 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. */ export const isValidCfop = (value: string | number): boolean => { if (!isLookupCode(value)) return false; - const code = typeof value === "number" ? String(value) : value.trim(); + const code = String(value).trim(); if (!CFOP_FORMAT_REGEX.test(code)) return false; diff --git a/src/is-valid-cnae/is-valid-cnae.test.ts b/src/is-valid-cnae/is-valid-cnae.test.ts index 4ca593ea5..bc6559737 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 2257c1f68..6a481bdd7 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-cnh/is-valid-cnh.ts b/src/is-valid-cnh/is-valid-cnh.ts index 25d91723e..c643a7bdd 100644 --- a/src/is-valid-cnh/is-valid-cnh.ts +++ b/src/is-valid-cnh/is-valid-cnh.ts @@ -1,9 +1,8 @@ import { calculateCnhFirstVerifier } from "../_internals/calculate-cnh-first-verifier/calculate-cnh-first-verifier"; import { calculateCnhSecondVerifier } from "../_internals/calculate-cnh-second-verifier/calculate-cnh-second-verifier"; +import { SEPARATORS_REGEX } from "../_internals/constants/separators"; import { isRepeatedDigits } from "../_internals/is-repeated-digits/is-repeated-digits"; -const SEPARATORS_REGEX = /[\s.-]/g; - const FORMAT_REGEX = /^\d{11}$/; /** diff --git a/src/is-valid-cno/is-valid-cno.ts b/src/is-valid-cno/is-valid-cno.ts index f49fa7d3e..9eb6cc86f 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-cnpj/constants.ts b/src/is-valid-cnpj/constants.ts deleted file mode 100644 index d5312c4a6..000000000 --- a/src/is-valid-cnpj/constants.ts +++ /dev/null @@ -1,12 +0,0 @@ -export const RESERVED_NUMBERS = [ - "00000000000000", - "11111111111111", - "22222222222222", - "33333333333333", - "44444444444444", - "55555555555555", - "66666666666666", - "77777777777777", - "88888888888888", - "99999999999999", -]; diff --git a/src/is-valid-cnpj/is-valid-cnpj.test.ts b/src/is-valid-cnpj/is-valid-cnpj.test.ts index 6d476b120..23ce07304 100644 --- a/src/is-valid-cnpj/is-valid-cnpj.test.ts +++ b/src/is-valid-cnpj/is-valid-cnpj.test.ts @@ -4,13 +4,25 @@ import { CNPJ_LENGTH } from "../_internals/constants/cnpj"; import { anyValue, digitsOfOtherLength, maskSeparators } from "../_internals/test/arbitraries"; import { bench, describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; import { generateCnpj } from "../generate-cnpj/generate-cnpj"; -import { RESERVED_NUMBERS } from "./constants"; import { isValidCnpj, type IsValidCnpjOptions } from "./is-valid-cnpj"; +const REPEATED_DIGITS = [ + "00000000000000", + "11111111111111", + "22222222222222", + "33333333333333", + "44444444444444", + "55555555555555", + "66666666666666", + "77777777777777", + "88888888888888", + "99999999999999", +]; + describe("isValidCnpj", () => { describe("should return false", () => { - test("when it is on the RESERVED_NUMBERS", () => { - for (const cnpj of RESERVED_NUMBERS) { + test("when every digit is the same", () => { + for (const cnpj of REPEATED_DIGITS) { expect(isValidCnpj(cnpj)).toBe(false); } }); diff --git a/src/is-valid-cnpj/is-valid-cnpj.ts b/src/is-valid-cnpj/is-valid-cnpj.ts index 701f3f55e..8709c8312 100644 --- a/src/is-valid-cnpj/is-valid-cnpj.ts +++ b/src/is-valid-cnpj/is-valid-cnpj.ts @@ -1,10 +1,8 @@ -import { - CNPJ_FIRST_DIGIT_WEIGHTS, - CNPJ_LENGTH, - CNPJ_SECOND_DIGIT_WEIGHTS, -} from "../_internals/constants/cnpj"; +import { calculateCnpjCheckDigit } from "../_internals/calculate-cnpj-check-digit/calculate-cnpj-check-digit"; +import { CNPJ_FIRST_DIGIT_WEIGHTS, CNPJ_SECOND_DIGIT_WEIGHTS } from "../_internals/constants/cnpj"; +import { isRepeatedDigits } from "../_internals/is-repeated-digits/is-repeated-digits"; +import { sanitizeToAlphanumeric } from "../_internals/sanitize-to-alphanumeric/sanitize-to-alphanumeric"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; -import { RESERVED_NUMBERS } from "./constants"; /** Options of `isValidCnpj`. */ export type IsValidCnpjOptions = { @@ -17,47 +15,11 @@ const FORMAT_REGEX = const NUMERIC_FORMAT_REGEX = /^\d{2}[\s.\-/]*\d{3}[\s.\-/]*\d{3}[\s.\-/]*\d{4}[\s.\-/]*\d{2}$/; -const cleanCnpj = (cnpj: string): string => { - let result = ""; - for (const char of cnpj) { - // Stryker disable next-line ConditionalExpression,EqualityOperator: this early exit only bounds how much of an oversized input is scanned; whatever length `result` ends up with, the caller's FORMAT_REGEX/NUMERIC_FORMAT_REGEX check still requires exactly CNPJ_LENGTH real characters and rejects anything else, so the exact cutoff point here never changes the final answer. - if (result.length > CNPJ_LENGTH) break; +const LETTER_REGEX = /[A-Z]/; - // Stryker disable next-line ConditionalExpression: the only characters that ever reach isValidChecksum are ones the caller's FORMAT_REGEX/NUMERIC_FORMAT_REGEX already restricted to "0"-"9", "A"-"Z" or a "\s.-/" separator (all below "0" in code point), so no reachable character can trigger this comparison's alternate branch without the whole match already having failed for an unrelated reason. - const isDigit = char >= "0" && char <= "9"; - // Stryker disable next-line ConditionalExpression: same reasoning as isDigit above — any character reaching here already satisfied FORMAT_REGEX/NUMERIC_FORMAT_REGEX, so it is always a genuine "0"-"9", "A"-"Z", "a"-"z" or a low-code-point separator. - const isUpper = char >= "A" && char <= "Z"; - // Stryker disable next-line ConditionalExpression: a character above "z" that this would wrongly accept is never itself "0"-"9"/"A"-"Z" or a "\s.-/" separator, and toUpperCase() cannot turn it into one either, so the caller's FORMAT_REGEX/NUMERIC_FORMAT_REGEX already rejects any string containing it, regardless of this classification. - const isLower = char >= "a" && char <= "z"; - - if (isDigit || isUpper || isLower) { - result += isLower ? String.fromCharCode(char.charCodeAt(0) - 32) : char; - } - } - return result; -}; - -const isValidChecksum = (cnpj: string): boolean => { - let sum = 0; - let position = 0; - for (const weight of CNPJ_FIRST_DIGIT_WEIGHTS) { - sum += (cnpj.charCodeAt(position) - 48) * weight; - position++; - } - let mod = sum % 11; - const expected1 = mod < 2 ? 48 : 48 + 11 - mod; - if (cnpj.charCodeAt(12) !== expected1) return false; - - sum = 0; - position = 0; - for (const weight of CNPJ_SECOND_DIGIT_WEIGHTS) { - sum += (cnpj.charCodeAt(position) - 48) * weight; - position++; - } - mod = sum % 11; - const expected2 = mod < 2 ? 48 : 48 + 11 - mod; - return cnpj.charCodeAt(13) === expected2; -}; +const isValidChecksum = (cnpj: string): boolean => + cnpj.charCodeAt(12) - 48 === calculateCnpjCheckDigit(cnpj, CNPJ_FIRST_DIGIT_WEIGHTS) && + cnpj.charCodeAt(13) - 48 === calculateCnpjCheckDigit(cnpj, CNPJ_SECOND_DIGIT_WEIGHTS); /** * Validates if a CNPJ (Cadastro Nacional da Pessoa Jurídica) is valid. @@ -97,34 +59,19 @@ const isValidChecksum = (cnpj: string): boolean => { export const isValidCnpj = (cnpj: string, options?: IsValidCnpjOptions): boolean => { if (typeof cnpj !== "string") return false; - const cleaned = cleanCnpj(cnpj); - const trimmed = cnpj.trim(); - const version = options?.version ?? 1; + if (options?.version === 2) { + const cleaned = sanitizeToAlphanumeric(cnpj); - let isNumeric = true; - - if (version === 2) { - // Stryker disable next-line EqualityOperator: cleaned.length is always exactly CNPJ_LENGTH here (checked above), so the extra i===CNPJ_LENGTH iteration reads charCodeAt(CNPJ_LENGTH), which is NaN and fails both boundary comparisons either way. - for (let i = 0; i < CNPJ_LENGTH; i++) { - const code = cleaned.charCodeAt(i); - // Stryker disable next-line ConditionalExpression,EqualityOperator: cleaned only ever holds "0"-"9"/"A"-"Z" characters (minimum code 48), so `code < 48` is always false and forcing it to a literal `false` changes nothing; and the only listed CNPJ reserved number whose raw checksum also happens to pass is "00000000000000" (code 48), so shifting the upper boundary to 57 (">=57") can never be told apart from the correct ">57" by any reachable input. - if (code < 48 || code > 57) { - isNumeric = false; - } + if (LETTER_REGEX.test(cleaned)) { + return FORMAT_REGEX.test(trimmed.toUpperCase()) && isValidChecksum(cleaned); } } - if (isNumeric) { - const numeric = sanitizeToDigits(cnpj); - - return ( - NUMERIC_FORMAT_REGEX.test(trimmed) && - !RESERVED_NUMBERS.includes(numeric) && - isValidChecksum(numeric) - ); - } + const numeric = sanitizeToDigits(cnpj); - return FORMAT_REGEX.test(trimmed.toUpperCase()) && isValidChecksum(cleaned); + return ( + NUMERIC_FORMAT_REGEX.test(trimmed) && !isRepeatedDigits(numeric) && isValidChecksum(numeric) + ); }; diff --git a/src/is-valid-cns/is-valid-cns.test.ts b/src/is-valid-cns/is-valid-cns.test.ts index 61f00a0c4..b8c66d145 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 3e4e7b341..50f3a4a11 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/constants.ts b/src/is-valid-cpf/constants.ts deleted file mode 100644 index 3be427519..000000000 --- a/src/is-valid-cpf/constants.ts +++ /dev/null @@ -1,12 +0,0 @@ -export const RESERVED_NUMBERS = [ - "00000000000", - "11111111111", - "22222222222", - "33333333333", - "44444444444", - "55555555555", - "66666666666", - "77777777777", - "88888888888", - "99999999999", -]; diff --git a/src/is-valid-cpf/is-valid-cpf.test.ts b/src/is-valid-cpf/is-valid-cpf.test.ts index 13abe7492..e639dcdb0 100644 --- a/src/is-valid-cpf/is-valid-cpf.test.ts +++ b/src/is-valid-cpf/is-valid-cpf.test.ts @@ -5,13 +5,25 @@ import { anyValue, digitsOfOtherLength, maskSeparators } from "../_internals/tes import { expectAlwaysReturnsType, expectRejected } from "../_internals/test/properties"; import { bench, describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; import { generateCpf } from "../generate-cpf/generate-cpf"; -import { RESERVED_NUMBERS } from "./constants"; import { isValidCpf } from "./is-valid-cpf"; +const REPEATED_DIGITS = [ + "00000000000", + "11111111111", + "22222222222", + "33333333333", + "44444444444", + "55555555555", + "66666666666", + "77777777777", + "88888888888", + "99999999999", +]; + describe("isValidCpf", () => { describe("should return false", () => { - test("when it is on the RESERVED_NUMBERS", () => { - for (const cpf of RESERVED_NUMBERS) { + test("when every digit is the same", () => { + for (const cpf of REPEATED_DIGITS) { expect(isValidCpf(cpf)).toBe(false); } }); @@ -94,6 +106,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 26fa1ded1..3462381fc 100644 --- a/src/is-valid-cpf/is-valid-cpf.ts +++ b/src/is-valid-cpf/is-valid-cpf.ts @@ -1,25 +1,12 @@ +import { calculateCpfCheckDigit } from "../_internals/calculate-cpf-check-digit/calculate-cpf-check-digit"; +import { isRepeatedDigits } from "../_internals/is-repeated-digits/is-repeated-digits"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; -import { RESERVED_NUMBERS } from "./constants"; const FORMAT_REGEX = /^\d{3}[\s.\-/]*\d{3}[\s.\-/]*\d{3}[\s.\-/]*\d{2}$/; -const isValidChecksum = (cpf: string): boolean => { - let sum = 0; - for (let i = 0; i < 9; i++) { - sum += (cpf.charCodeAt(i) - 48) * (10 - i); - } - let mod = sum % 11; - const expected1 = mod < 2 ? 48 : 48 + 11 - mod; - if (cpf.charCodeAt(9) !== expected1) return false; - - sum = 0; - for (let i = 0; i < 10; i++) { - sum += (cpf.charCodeAt(i) - 48) * (11 - i); - } - mod = sum % 11; - const expected2 = mod < 2 ? 48 : 48 + 11 - mod; - return cpf.charCodeAt(10) === expected2; -}; +const isValidChecksum = (cpf: string): boolean => + cpf.charCodeAt(9) - 48 === calculateCpfCheckDigit(cpf.slice(0, 9)) && + cpf.charCodeAt(10) - 48 === calculateCpfCheckDigit(cpf.slice(0, 10)); /** * Validates if a CPF (Cadastro de Pessoas Físicas) is valid. @@ -38,11 +25,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 => { @@ -52,7 +46,7 @@ export const isValidCpf = (cpf: string): boolean => { if (!FORMAT_REGEX.test(cpf.trim())) return false; - if (RESERVED_NUMBERS.includes(digits)) return false; + if (isRepeatedDigits(digits)) return false; return isValidChecksum(digits); }; 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 121d1664e..ea04f28c7 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 02c974d6b..335f871f0 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 565ac087b..88a8d67ba 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 c699ebb24..3c29b5438 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 a983076de..68edb3c78 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. * @@ -34,7 +39,7 @@ import { CSOSN_CODES, CSOSN_FORMAT_REGEX } from "./constants"; export const isValidCsosn = (value: string | number): boolean => { if (!isLookupCode(value)) return false; - const code = typeof value === "number" ? String(value) : value.trim(); + const code = String(value).trim(); if (!CSOSN_FORMAT_REGEX.test(code)) return false; diff --git a/src/is-valid-cst/constants.ts b/src/is-valid-cst/constants.ts index 84ea07aa7..1d448951e 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 7a861ffd0..d6ac8d99c 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 411c1c223..9b8226b71 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 fdff24e05..78af6f084 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 adbfe275c..34b721120 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 e6c2d396a..14738974e 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 dfbbc033d..634c9db14 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/constants.ts b/src/is-valid-ie/constants.ts index 281ff7a29..21408ee5b 100644 --- a/src/is-valid-ie/constants.ts +++ b/src/is-valid-ie/constants.ts @@ -4,6 +4,8 @@ * @see Official: http://www.sintegra.gov.br/insc_est.html */ +export const AL_PREFIXES = ["24"]; + export const BA_MOD_10_DIGITS = [0, 1, 2, 3, 4, 5, 8]; export const GO_PREFIXES = ["10", "11", "15"]; diff --git a/src/is-valid-ie/is-valid-ie.test.ts b/src/is-valid-ie/is-valid-ie.test.ts index f0598be52..0e24bb373 100644 --- a/src/is-valid-ie/is-valid-ie.test.ts +++ b/src/is-valid-ie/is-valid-ie.test.ts @@ -2,814 +2,955 @@ import * as fc from "fast-check"; import { DATA as STATES, type StateCode } from "../_internals/constants/states"; import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; -import { isValidIe } from "./is-valid-ie"; +import { type IsValidIeParams, isValidIe } from "./is-valid-ie"; describe("isValidIe", () => { describe("AC", () => { test("should return true for a valid IE, including one with formatting characters", () => { - expect(isValidIe("AC", "0108368143106")).toBe(true); - expect(isValidIe("AC", "01.349.541/474-57")).toBe(true); + expect(isValidIe({ value: "0108368143106", stateCode: "AC" })).toBe(true); + expect(isValidIe({ value: "01.349.541/474-57", stateCode: "AC" })).toBe(true); }); test("should return false when the second verifier digit is incorrect", () => { - expect(isValidIe("AC", "0187634580933")).toBe(false); + expect(isValidIe({ value: "0187634580933", stateCode: "AC" })).toBe(false); }); test("should return false when the first verifier digit is incorrect", () => { - expect(isValidIe("AC", "0187634580924")).toBe(false); + expect(isValidIe({ value: "0187634580924", stateCode: "AC" })).toBe(false); }); test("should return false when the IE does not start with 01", () => { - expect(isValidIe("AC", "0018763458000")).toBe(false); + expect(isValidIe({ value: "0018763458000", stateCode: "AC" })).toBe(false); }); test("should return false when the length is bigger than 13", () => { - expect(isValidIe("AC", "01018763458064")).toBe(false); + expect(isValidIe({ value: "01018763458064", stateCode: "AC" })).toBe(false); }); test("should return false when only the first verifier digit is wrong, even though the second still matches its recomputed value", () => { - expect(isValidIe("AC", "0108368143116")).toBe(false); + expect(isValidIe({ value: "0108368143116", stateCode: "AC" })).toBe(false); }); test("should return false when the length is 14, even though the verifier digits still sit at the valid positions", () => { - expect(isValidIe("AC", "01083681431069")).toBe(false); + expect(isValidIe({ value: "01083681431069", stateCode: "AC" })).toBe(false); }); }); describe("AL", () => { test("should return true for a valid IE", () => { - expect(isValidIe("AL", "248659758")).toBe(true); + expect(isValidIe({ value: "248659758", stateCode: "AL" })).toBe(true); }); test("should return true when the check digit 10 is converted to 0", () => { - expect(isValidIe("AL", "247424170")).toBe(true); + expect(isValidIe({ value: "247424170", stateCode: "AL" })).toBe(true); }); test("should return false when the verifier digit is incorrect", () => { - expect(isValidIe("AL", "248659759")).toBe(false); + expect(isValidIe({ value: "248659759", stateCode: "AL" })).toBe(false); }); test("should return false when the IE does not start with 24", () => { - expect(isValidIe("AL", "258659750")).toBe(false); + expect(isValidIe({ value: "258659750", stateCode: "AL" })).toBe(false); }); test("should return false when the length is more than 9", () => { - expect(isValidIe("AL", "2486597584")).toBe(false); + expect(isValidIe({ value: "2486597584", stateCode: "AL" })).toBe(false); }); test("should return true for another valid IE", () => { - expect(isValidIe("AL", "240000005")).toBe(true); + expect(isValidIe({ value: "240000005", stateCode: "AL" })).toBe(true); }); }); describe("AP", () => { test("should return true for valid IEs", () => { - expect(isValidIe("AP", "036029572")).toBe(true); - expect(isValidIe("AP", "030123459")).toBe(true); - expect(isValidIe("AP", "030000080")).toBe(true); - expect(isValidIe("AP", "030000160")).toBe(true); - expect(isValidIe("AP", "030170011")).toBe(true); - expect(isValidIe("AP", "030170020")).toBe(true); - expect(isValidIe("AP", "030170071")).toBe(true); + expect(isValidIe({ value: "036029572", stateCode: "AP" })).toBe(true); + expect(isValidIe({ value: "030123459", stateCode: "AP" })).toBe(true); + expect(isValidIe({ value: "030000080", stateCode: "AP" })).toBe(true); + expect(isValidIe({ value: "030000160", stateCode: "AP" })).toBe(true); + expect(isValidIe({ value: "030170011", stateCode: "AP" })).toBe(true); + expect(isValidIe({ value: "030170020", stateCode: "AP" })).toBe(true); + expect(isValidIe({ value: "030170071", stateCode: "AP" })).toBe(true); }); test("should return false when the verifier digit is incorrect", () => { - expect(isValidIe("AP", "036029573")).toBe(false); + expect(isValidIe({ value: "036029573", stateCode: "AP" })).toBe(false); }); test("should return false when the length is more than 9 digits", () => { - expect(isValidIe("AP", "0306029570")).toBe(false); + expect(isValidIe({ value: "0306029570", stateCode: "AP" })).toBe(false); }); test("should return false when the IE does not start with 03", () => { - expect(isValidIe("AP", "003060292")).toBe(false); + expect(isValidIe({ value: "003060292", stateCode: "AP" })).toBe(false); }); test("should return true when the inscricao is exactly 3000000, one below the special range starting at 3000001", () => { - expect(isValidIe("AP", "030000009")).toBe(true); + expect(isValidIe({ value: "030000009", stateCode: "AP" })).toBe(true); }); test("should return true when the inscricao is exactly 3019023, one above the special range ending at 3019022", () => { - expect(isValidIe("AP", "030190231")).toBe(true); + expect(isValidIe({ value: "030190231", stateCode: "AP" })).toBe(true); }); test("should return true when the inscricao is exactly 3000001, the inclusive lower bound of the first special range", () => { - expect(isValidIe("AP", "030000012")).toBe(true); + expect(isValidIe({ value: "030000012", stateCode: "AP" })).toBe(true); }); test("should return true when the inscricao is exactly 3017000, the inclusive upper bound of the first special range", () => { - expect(isValidIe("AP", "030170007")).toBe(true); + expect(isValidIe({ value: "030170007", stateCode: "AP" })).toBe(true); }); test("should return true when the inscricao is exactly 3019022, the inclusive upper bound of the second special range", () => { - expect(isValidIe("AP", "030190225")).toBe(true); + expect(isValidIe({ value: "030190225", stateCode: "AP" })).toBe(true); }); }); describe("AM", () => { test("should return true for valid IEs, including one with formatting characters", () => { - expect(isValidIe("AM", "48.063.523-4")).toBe(true); - expect(isValidIe("AM", "036029572")).toBe(true); - expect(isValidIe("AM", "000000019")).toBe(true); - expect(isValidIe("AM", "046893830")).toBe(true); + expect(isValidIe({ value: "48.063.523-4", stateCode: "AM" })).toBe(true); + expect(isValidIe({ value: "036029572", stateCode: "AM" })).toBe(true); + expect(isValidIe({ value: "000000019", stateCode: "AM" })).toBe(true); + expect(isValidIe({ value: "046893830", stateCode: "AM" })).toBe(true); }); test("should return false when the verifier digit is incorrect", () => { - expect(isValidIe("AM", "036029573")).toBe(false); + expect(isValidIe({ value: "036029573", stateCode: "AM" })).toBe(false); }); test("should return false when the length is more than 9 digits", () => { - expect(isValidIe("AM", "0036029572")).toBe(false); + expect(isValidIe({ value: "0036029572", stateCode: "AM" })).toBe(false); }); test("should return false when the length is 10, even though the first eight digits alone would form a valid checksum", () => { - expect(isValidIe("AM", "0468938309")).toBe(false); + expect(isValidIe({ value: "0468938309", stateCode: "AM" })).toBe(false); }); }); describe("BA", () => { test("should return true for an 8-digit IE using the mod 10 rule", () => { - expect(isValidIe("BA", "12345663")).toBe(true); + expect(isValidIe({ value: "12345663", stateCode: "BA" })).toBe(true); }); test("should return true for an 8-digit IE using the mod 11 rule", () => { - expect(isValidIe("BA", "74219145")).toBe(true); + expect(isValidIe({ value: "74219145", stateCode: "BA" })).toBe(true); }); test("should return true for a 9-digit IE using the mod 10 rule", () => { - expect(isValidIe("BA", "038343081")).toBe(true); - expect(isValidIe("BA", "100000306")).toBe(true); + expect(isValidIe({ value: "038343081", stateCode: "BA" })).toBe(true); + expect(isValidIe({ value: "100000306", stateCode: "BA" })).toBe(true); }); test("should return true for a 9-digit IE using the mod 11 rule", () => { - expect(isValidIe("BA", "778514741")).toBe(true); + expect(isValidIe({ value: "778514741", stateCode: "BA" })).toBe(true); }); test("should return true for a 9-digit IE starting with 0", () => { - expect(isValidIe("BA", "078771760")).toBe(true); - expect(isValidIe("BA", "039474751")).toBe(true); - expect(isValidIe("BA", "090529323")).toBe(true); + expect(isValidIe({ value: "078771760", stateCode: "BA" })).toBe(true); + expect(isValidIe({ value: "039474751", stateCode: "BA" })).toBe(true); + expect(isValidIe({ value: "090529323", stateCode: "BA" })).toBe(true); }); test("should return true for an 8-digit IE starting with 0", () => { - expect(isValidIe("BA", "04772253")).toBe(true); + expect(isValidIe({ value: "04772253", stateCode: "BA" })).toBe(true); }); test("should return false for an 8-digit IE with an incorrect mod 10 digit", () => { - expect(isValidIe("BA", "12345636")).toBe(false); + expect(isValidIe({ value: "12345636", stateCode: "BA" })).toBe(false); }); test("should return false for an 8-digit IE with an incorrect mod 11 digit", () => { - expect(isValidIe("BA", "74219154")).toBe(false); + expect(isValidIe({ value: "74219154", stateCode: "BA" })).toBe(false); }); test("should return false for a 9-digit IE with an incorrect mod 10 digit", () => { - expect(isValidIe("BA", "038343001")).toBe(false); + expect(isValidIe({ value: "038343001", stateCode: "BA" })).toBe(false); }); test("should return false for a 9-digit IE with an incorrect mod 11 digit", () => { - expect(isValidIe("BA", "778514731")).toBe(false); + expect(isValidIe({ value: "778514731", stateCode: "BA" })).toBe(false); }); test("should return false when the length is more than 9 digits", () => { - expect(isValidIe("BA", "0012345636")).toBe(false); + expect(isValidIe({ value: "0012345636", stateCode: "BA" })).toBe(false); }); test("should return false when the length is 10, even though the digits would satisfy the checksum formula for that length", () => { - expect(isValidIe("BA", "1234567804")).toBe(false); + expect(isValidIe({ value: "1234567804", stateCode: "BA" })).toBe(false); }); test("should return false when only the second verifier digit is wrong, even though the first still matches its recomputed value", () => { - expect(isValidIe("BA", "778514740")).toBe(false); + expect(isValidIe({ value: "778514740", stateCode: "BA" })).toBe(false); }); }); describe("CE", () => { test("should return true for a valid IE", () => { - expect(isValidIe("CE", "853511942")).toBe(true); + expect(isValidIe({ value: "853511942", stateCode: "CE" })).toBe(true); }); test("should return false when the digit is incorrect", () => { - expect(isValidIe("CE", "853511943")).toBe(false); + expect(isValidIe({ value: "853511943", stateCode: "CE" })).toBe(false); }); test("should return false when the length is more than 9 digits", () => { - expect(isValidIe("CE", "0853511942")).toBe(false); + expect(isValidIe({ value: "0853511942", stateCode: "CE" })).toBe(false); }); }); describe("DF", () => { test("should return true for a valid IE", () => { - expect(isValidIe("DF", "0754002000176")).toBe(true); + expect(isValidIe({ value: "0754002000176", stateCode: "DF" })).toBe(true); }); test("should return true when the tenth digit is converted to 0", () => { - expect(isValidIe("DF", "0754002000508")).toBe(true); + expect(isValidIe({ value: "0754002000508", stateCode: "DF" })).toBe(true); }); test("should return false when the IE does not start with 07", () => { - expect(isValidIe("DF", "0108368143017")).toBe(false); + expect(isValidIe({ value: "0108368143017", stateCode: "DF" })).toBe(false); }); test("should return false when the length is not 13 digits", () => { - expect(isValidIe("DF", "07008368143094")).toBe(false); + expect(isValidIe({ value: "07008368143094", stateCode: "DF" })).toBe(false); }); test("should return false when the digit is incorrect", () => { - expect(isValidIe("DF", "0754002000175")).toBe(false); + expect(isValidIe({ value: "0754002000175", stateCode: "DF" })).toBe(false); }); test("should return false when only the first verifier digit is wrong, even though the second still matches its recomputed value", () => { - expect(isValidIe("DF", "0754002000186")).toBe(false); + expect(isValidIe({ value: "0754002000186", stateCode: "DF" })).toBe(false); }); }); describe("ES", () => { test("should return true for a valid IE", () => { - expect(isValidIe("ES", "639191444")).toBe(true); + expect(isValidIe({ value: "639191444", stateCode: "ES" })).toBe(true); }); test("should return false when the digit is incorrect", () => { - expect(isValidIe("ES", "639191445")).toBe(false); + expect(isValidIe({ value: "639191445", stateCode: "ES" })).toBe(false); }); test("should return false when the length is more than 9 digits", () => { - expect(isValidIe("ES", "0639191444")).toBe(false); + expect(isValidIe({ value: "0639191444", stateCode: "ES" })).toBe(false); }); }); describe("GO", () => { test("should return true for a valid IE using the base rule", () => { - expect(isValidIe("GO", "109161793")).toBe(true); + expect(isValidIe({ value: "109161793", stateCode: "GO" })).toBe(true); }); test("should return true for an IE with prefix 15", () => { - expect(isValidIe("GO", "159876540")).toBe(true); + expect(isValidIe({ value: "159876540", stateCode: "GO" })).toBe(true); }); test("should return true when the remainder is 1 and the inscricao falls inside the special range 10103105..10119997", () => { - expect(isValidIe("GO", "101031051")).toBe(true); + expect(isValidIe({ value: "101031051", stateCode: "GO" })).toBe(true); }); test("should return true when the remainder is 0", () => { - expect(isValidIe("GO", "101030940")).toBe(true); + expect(isValidIe({ value: "101030940", stateCode: "GO" })).toBe(true); }); test("should return true for IE 11094402, which accepts both digit 0 and digit 1", () => { - expect(isValidIe("GO", "110944020")).toBe(true); - expect(isValidIe("GO", "110944021")).toBe(true); + expect(isValidIe({ value: "110944020", stateCode: "GO" })).toBe(true); + expect(isValidIe({ value: "110944021", stateCode: "GO" })).toBe(true); }); test("should return false when the verified digit is incorrect", () => { - expect(isValidIe("GO", "109161794")).toBe(false); + expect(isValidIe({ value: "109161794", stateCode: "GO" })).toBe(false); }); test("should return false when the IE does not start with 10, 11 or 15", () => { - expect(isValidIe("GO", "121031131")).toBe(false); + expect(isValidIe({ value: "121031131", stateCode: "GO" })).toBe(false); }); test("should return false for prefixes 20 to 29, which the current SEFAZ-GO rule does not accept", () => { - expect(isValidIe("GO", "209876549")).toBe(false); + expect(isValidIe({ value: "209876549", stateCode: "GO" })).toBe(false); }); test("should return false when the length is different from 9", () => { - expect(isValidIe("GO", "0101030940")).toBe(false); + expect(isValidIe({ value: "0101030940", stateCode: "GO" })).toBe(false); }); test("should return false when the remainder is 0, since the special range rule must not apply (10103113 has remainder 0, so the digit is 0, not 1)", () => { - expect(isValidIe("GO", "101031131")).toBe(false); - expect(isValidIe("GO", "101031130")).toBe(true); + expect(isValidIe({ value: "101031131", stateCode: "GO" })).toBe(false); + expect(isValidIe({ value: "101031130", stateCode: "GO" })).toBe(true); }); test("should return false when the remainder is 1 but the inscricao (10000007) falls outside the special range 10103105..10119997, so the digit is 0", () => { - expect(isValidIe("GO", "100000071")).toBe(false); - expect(isValidIe("GO", "100000070")).toBe(true); + expect(isValidIe({ value: "100000071", stateCode: "GO" })).toBe(false); + expect(isValidIe({ value: "100000070", stateCode: "GO" })).toBe(true); }); test("should return false when the length is 10, even though the first eight digits alone would form a valid checksum", () => { - expect(isValidIe("GO", "1091617930")).toBe(false); + expect(isValidIe({ value: "1091617930", stateCode: "GO" })).toBe(false); }); test("should return false for IE 11094402 when the digit is neither 0 nor 1", () => { - expect(isValidIe("GO", "110944022")).toBe(false); + expect(isValidIe({ value: "110944022", stateCode: "GO" })).toBe(false); }); test("should return true when the remainder is 1 and the inscricao (10103086) falls just below the special range 10103105..10119997, so the digit is 0", () => { - expect(isValidIe("GO", "101030860")).toBe(true); + expect(isValidIe({ value: "101030860", stateCode: "GO" })).toBe(true); }); test("should return true when the remainder is 1 and the inscricao (10120003) falls just above the special range 10103105..10119997, so the digit is 0", () => { - expect(isValidIe("GO", "101200030")).toBe(true); + expect(isValidIe({ value: "101200030", stateCode: "GO" })).toBe(true); }); test("should return true when the remainder is 1 and the inscricao is exactly 10119997, the inclusive upper bound of the special range", () => { - expect(isValidIe("GO", "101199971")).toBe(true); + expect(isValidIe({ value: "101199971", stateCode: "GO" })).toBe(true); }); }); describe("MA", () => { test("should return true for a valid IE using the base rule", () => { - expect(isValidIe("MA", "120000008")).toBe(true); + expect(isValidIe({ value: "120000008", stateCode: "MA" })).toBe(true); }); test("should return true when digit 11 is converted to zero", () => { - expect(isValidIe("MA", "120000040")).toBe(true); + expect(isValidIe({ value: "120000040", stateCode: "MA" })).toBe(true); }); test("should return true when digit 10 is converted to 1", () => { - expect(isValidIe("MA", "120000130")).toBe(true); + expect(isValidIe({ value: "120000130", stateCode: "MA" })).toBe(true); }); test("should return false when the verified digit is incorrect", () => { - expect(isValidIe("MA", "120000007")).toBe(false); + expect(isValidIe({ value: "120000007", stateCode: "MA" })).toBe(false); }); test("should return false when the IE does not start with 12", () => { - expect(isValidIe("MA", "109161793")).toBe(false); + expect(isValidIe({ value: "109161793", stateCode: "MA" })).toBe(false); }); test("should return false when the length is different from 9", () => { - expect(isValidIe("MA", "0120000008")).toBe(false); + expect(isValidIe({ value: "0120000008", stateCode: "MA" })).toBe(false); }); }); describe("MG", () => { test("should return true for a valid IE using the base rule", () => { - expect(isValidIe("MG", "4333908330177")).toBe(true); + expect(isValidIe({ value: "4333908330177", stateCode: "MG" })).toBe(true); }); test("should return true when the first check digit 10 is converted to 0", () => { - expect(isValidIe("MG", "4333908330410")).toBe(true); - expect(isValidIe("MG", "7489439278602")).toBe(true); + expect(isValidIe({ value: "4333908330410", stateCode: "MG" })).toBe(true); + expect(isValidIe({ value: "7489439278602", stateCode: "MG" })).toBe(true); }); test("should return true when the second check digit 11 is converted to 0", () => { - expect(isValidIe("MG", "4333908332560")).toBe(true); + expect(isValidIe({ value: "4333908332560", stateCode: "MG" })).toBe(true); }); test("should return false when the first verified digit is incorrect", () => { - expect(isValidIe("MG", "4333908330167")).toBe(false); + expect(isValidIe({ value: "4333908330167", stateCode: "MG" })).toBe(false); }); test("should return false when the length is different from 13", () => { - expect(isValidIe("MG", "04333908330177")).toBe(false); + expect(isValidIe({ value: "04333908330177", stateCode: "MG" })).toBe(false); }); test("should return false when the second verified digit is incorrect", () => { - expect(isValidIe("MG", "4333908330176")).toBe(false); + expect(isValidIe({ value: "4333908330176", stateCode: "MG" })).toBe(false); }); test("should return false when the length is 14, even though the verifier digits still sit at the valid positions", () => { - expect(isValidIe("MG", "43339083301770")).toBe(false); + expect(isValidIe({ value: "43339083301770", stateCode: "MG" })).toBe(false); }); }); describe("MT", () => { test("should return true for a valid IE using the base rule", () => { - expect(isValidIe("MT", "60474120469")).toBe(true); + expect(isValidIe({ value: "60474120469", stateCode: "MT" })).toBe(true); }); test("should return false when the verified digit is incorrect", () => { - expect(isValidIe("MT", "12345678901")).toBe(false); + expect(isValidIe({ value: "12345678901", stateCode: "MT" })).toBe(false); }); test("should return false when the length is different from 11", () => { - expect(isValidIe("MT", "1234567890112")).toBe(false); + expect(isValidIe({ value: "1234567890112", stateCode: "MT" })).toBe(false); }); test("should return false when the length is 12, even though the first ten digits alone would form a valid checksum", () => { - expect(isValidIe("MT", "604741204699")).toBe(false); + expect(isValidIe({ value: "604741204699", stateCode: "MT" })).toBe(false); }); }); describe("MS", () => { test("should return true for a valid IE using the base rule", () => { - expect(isValidIe("MS", "280000006")).toBe(true); + expect(isValidIe({ value: "280000006", stateCode: "MS" })).toBe(true); }); test("should return true when digit 10 is converted to 0", () => { - expect(isValidIe("MS", "280000090")).toBe(true); + expect(isValidIe({ value: "280000090", stateCode: "MS" })).toBe(true); }); test("should return true when digit 11 is converted to 0", () => { - expect(isValidIe("MS", "280000030")).toBe(true); + expect(isValidIe({ value: "280000030", stateCode: "MS" })).toBe(true); }); test("should return true for an IE with prefix 50", () => { - expect(isValidIe("MS", "500000000")).toBe(true); + expect(isValidIe({ value: "500000000", stateCode: "MS" })).toBe(true); }); test("should return false when the verified digit is incorrect", () => { - expect(isValidIe("MS", "280000031")).toBe(false); + expect(isValidIe({ value: "280000031", stateCode: "MS" })).toBe(false); }); test("should return false when the length is different from 9", () => { - expect(isValidIe("MS", "0280000006")).toBe(false); + expect(isValidIe({ value: "0280000006", stateCode: "MS" })).toBe(false); }); test("should return false when the IE does not start with 28", () => { - expect(isValidIe("MS", "853511942")).toBe(false); + expect(isValidIe({ value: "853511942", stateCode: "MS" })).toBe(false); }); }); describe("PA", () => { test("should return true for a valid IE using the base rule", () => { - expect(isValidIe("PA", "150000006")).toBe(true); + expect(isValidIe({ value: "150000006", stateCode: "PA" })).toBe(true); }); test("should return true when digit 10 is converted to 0", () => { - expect(isValidIe("PA", "150000260")).toBe(true); + expect(isValidIe({ value: "150000260", stateCode: "PA" })).toBe(true); }); test("should return true when digit 11 is converted to 0", () => { - expect(isValidIe("PA", "150000030")).toBe(true); + expect(isValidIe({ value: "150000030", stateCode: "PA" })).toBe(true); }); test("should return true for IEs with prefixes 75 to 79", () => { - expect(isValidIe("PA", "750000023")).toBe(true); - expect(isValidIe("PA", "760000000")).toBe(true); - expect(isValidIe("PA", "770000002")).toBe(true); - expect(isValidIe("PA", "780000005")).toBe(true); - expect(isValidIe("PA", "790000008")).toBe(true); + expect(isValidIe({ value: "750000023", stateCode: "PA" })).toBe(true); + expect(isValidIe({ value: "760000000", stateCode: "PA" })).toBe(true); + expect(isValidIe({ value: "770000002", stateCode: "PA" })).toBe(true); + expect(isValidIe({ value: "780000005", stateCode: "PA" })).toBe(true); + expect(isValidIe({ value: "790000008", stateCode: "PA" })).toBe(true); }); test("should return false when the IE does not start with 15, 75, 76, 77, 78 or 79", () => { - expect(isValidIe("PA", "120000008")).toBe(false); + expect(isValidIe({ value: "120000008", stateCode: "PA" })).toBe(false); }); test("should return false when the length is different from 9", () => { - expect(isValidIe("PA", "0150000006")).toBe(false); + expect(isValidIe({ value: "0150000006", stateCode: "PA" })).toBe(false); }); test("should return false when the digit is incorrect", () => { - expect(isValidIe("PA", "150000007")).toBe(false); + expect(isValidIe({ value: "150000007", stateCode: "PA" })).toBe(false); }); }); describe("PB", () => { test("should return true for a valid IE using the base rule", () => { - expect(isValidIe("PB", "853511942")).toBe(true); + expect(isValidIe({ value: "853511942", stateCode: "PB" })).toBe(true); }); test("should return true when digit 10 is converted to 0", () => { - expect(isValidIe("PB", "853512230")).toBe(true); + expect(isValidIe({ value: "853512230", stateCode: "PB" })).toBe(true); }); test("should return true when digit 11 is converted to 0", () => { - expect(isValidIe("PB", "853511950")).toBe(true); + expect(isValidIe({ value: "853511950", stateCode: "PB" })).toBe(true); }); test("should return false when the length is different from 9", () => { - expect(isValidIe("PB", "0853511942")).toBe(false); + expect(isValidIe({ value: "0853511942", stateCode: "PB" })).toBe(false); }); test("should return false when the digit is incorrect", () => { - expect(isValidIe("PB", "853511943")).toBe(false); + expect(isValidIe({ value: "853511943", stateCode: "PB" })).toBe(false); }); }); describe("PE", () => { test("should return true for a valid IE using the base rule", () => { - expect(isValidIe("PE", "288625706")).toBe(true); + expect(isValidIe({ value: "288625706", stateCode: "PE" })).toBe(true); }); test("should return false when the length is different from 9 digits", () => { - expect(isValidIe("PE", "0925870110")).toBe(false); + expect(isValidIe({ value: "0925870110", stateCode: "PE" })).toBe(false); }); test("should return false when the digit is incorrect", () => { - expect(isValidIe("PE", "925870101")).toBe(false); + expect(isValidIe({ value: "925870101", stateCode: "PE" })).toBe(false); }); test("should return true for a valid IE whose first verifier digit is not zero", () => { - expect(isValidIe("PE", "123456797")).toBe(true); + expect(isValidIe({ value: "123456797", stateCode: "PE" })).toBe(true); }); test("should return false when only the second verifier digit is wrong, even though the first still matches", () => { - expect(isValidIe("PE", "123456790")).toBe(false); + expect(isValidIe({ value: "123456790", stateCode: "PE" })).toBe(false); }); test("should return false when only the first verifier digit is wrong, even though the second still matches", () => { - expect(isValidIe("PE", "123456787")).toBe(false); + expect(isValidIe({ value: "123456787", stateCode: "PE" })).toBe(false); }); }); describe("PI", () => { test("should return true for a valid IE using the base rule", () => { - expect(isValidIe("PI", "052364534")).toBe(true); + expect(isValidIe({ value: "052364534", stateCode: "PI" })).toBe(true); }); }); describe("PR", () => { test("should return true for a valid IE using the base rule", () => { - expect(isValidIe("PR", "4447953604")).toBe(true); + expect(isValidIe({ value: "4447953604", stateCode: "PR" })).toBe(true); }); test("should return true when the first check digit is below 10 and needs no clamping", () => { - expect(isValidIe("PR", "0000000191")).toBe(true); + expect(isValidIe({ value: "0000000191", stateCode: "PR" })).toBe(true); }); test("should return true when the second check digit is the exceptional 10, clamped to 0", () => { - expect(isValidIe("PR", "0000000000")).toBe(true); + expect(isValidIe({ value: "0000000000", stateCode: "PR" })).toBe(true); }); test("should return false when the length is different from 10 digits", () => { - expect(isValidIe("PR", "04447953604")).toBe(false); + expect(isValidIe({ value: "04447953604", stateCode: "PR" })).toBe(false); }); test("should return false when the digit is incorrect", () => { - expect(isValidIe("PR", "4447953640")).toBe(false); + expect(isValidIe({ value: "4447953640", stateCode: "PR" })).toBe(false); }); test("should return false when the length is 11, even though the verifier digits still sit at the valid positions", () => { - expect(isValidIe("PR", "44479536044")).toBe(false); + expect(isValidIe({ value: "44479536044", stateCode: "PR" })).toBe(false); }); test("should return false when only the second verifier digit is wrong, even though the first still matches", () => { - expect(isValidIe("PR", "4447953600")).toBe(false); + expect(isValidIe({ value: "4447953600", stateCode: "PR" })).toBe(false); }); test("should return false when only the first verifier digit is wrong, even though the second still matches", () => { - expect(isValidIe("PR", "4447953614")).toBe(false); + expect(isValidIe({ value: "4447953614", stateCode: "PR" })).toBe(false); }); }); describe("RJ", () => { test("should return true for a valid IE using the base rule", () => { - expect(isValidIe("RJ", "62545372")).toBe(true); + expect(isValidIe({ value: "62545372", stateCode: "RJ" })).toBe(true); }); test("should return true when digit 10 is converted to 0", () => { - expect(isValidIe("RJ", "62545470")).toBe(true); + expect(isValidIe({ value: "62545470", stateCode: "RJ" })).toBe(true); }); test("should return true when digit 11 is converted to 0", () => { - expect(isValidIe("RJ", "62545380")).toBe(true); + expect(isValidIe({ value: "62545380", stateCode: "RJ" })).toBe(true); }); test("should return false when the first verified digit is incorrect", () => { - expect(isValidIe("RJ", "20441620")).toBe(false); + expect(isValidIe({ value: "20441620", stateCode: "RJ" })).toBe(false); }); test("should return false when the length is different from 8", () => { - expect(isValidIe("RJ", "020441623")).toBe(false); + expect(isValidIe({ value: "020441623", stateCode: "RJ" })).toBe(false); }); test("should return false when the length is 9, even though the verifier digit still sits at the valid position", () => { - expect(isValidIe("RJ", "625453720")).toBe(false); + expect(isValidIe({ value: "625453720", stateCode: "RJ" })).toBe(false); }); }); describe("RN", () => { test("should return true for a valid IE using the base rule", () => { - expect(isValidIe("RN", "2007693232")).toBe(true); + expect(isValidIe({ value: "2007693232", stateCode: "RN" })).toBe(true); }); test("should return true when digit 10 is converted to 0", () => { - expect(isValidIe("RN", "2003569880")).toBe(true); + expect(isValidIe({ value: "2003569880", stateCode: "RN" })).toBe(true); }); test("should return true for an old-format IE", () => { - expect(isValidIe("RN", "203569881")).toBe(true); + expect(isValidIe({ value: "203569881", stateCode: "RN" })).toBe(true); }); test("should return false when the first verified digit is incorrect", () => { - expect(isValidIe("RN", "2007693231")).toBe(false); + expect(isValidIe({ value: "2007693231", stateCode: "RN" })).toBe(false); }); test("should return false when the IE does not start with 20", () => { - expect(isValidIe("RN", "0203569881")).toBe(false); + expect(isValidIe({ value: "0203569881", stateCode: "RN" })).toBe(false); }); test("should return false when the length is different from 9 or 10", () => { - expect(isValidIe("RN", "20356988104")).toBe(false); + expect(isValidIe({ value: "20356988104", stateCode: "RN" })).toBe(false); }); }); describe("RO", () => { test("should return true for a valid IE using the base rule", () => { - expect(isValidIe("RO", "01078042249629")).toBe(true); + expect(isValidIe({ value: "01078042249629", stateCode: "RO" })).toBe(true); }); test("should return true when digit 10 is converted to 0", () => { - expect(isValidIe("RO", "01078042249670")).toBe(true); + expect(isValidIe({ value: "01078042249670", stateCode: "RO" })).toBe(true); }); test("should return true when digit 11 is converted to 0", () => { - expect(isValidIe("RO", "01078042249751")).toBe(true); + expect(isValidIe({ value: "01078042249751", stateCode: "RO" })).toBe(true); }); test("should return false when the first verified digit is incorrect", () => { - expect(isValidIe("RO", "01078042249756")).toBe(false); + expect(isValidIe({ value: "01078042249756", stateCode: "RO" })).toBe(false); }); test("should return false when the length is different from 14", () => { - expect(isValidIe("RO", "001078042249627")).toBe(false); + expect(isValidIe({ value: "001078042249627", stateCode: "RO" })).toBe(false); }); test("should return true for another valid IE that exercises the wrap-around weight", () => { - expect(isValidIe("RO", "12345678901231")).toBe(true); + expect(isValidIe({ value: "12345678901231", stateCode: "RO" })).toBe(true); }); }); describe("RR", () => { test("should return true for a valid IE using the base rule", () => { - expect(isValidIe("RR", "240061536")).toBe(true); + expect(isValidIe({ value: "240061536", stateCode: "RR" })).toBe(true); }); test("should return false when the first verified digit is incorrect", () => { - expect(isValidIe("RR", "240061537")).toBe(false); + expect(isValidIe({ value: "240061537", stateCode: "RR" })).toBe(false); }); test("should return false when the length is different from 9", () => { - expect(isValidIe("RR", "2400615366")).toBe(false); + expect(isValidIe({ value: "2400615366", stateCode: "RR" })).toBe(false); }); test("should return false when the IE does not start with 24", () => { - expect(isValidIe("RR", "024006150")).toBe(false); + expect(isValidIe({ value: "024006150", stateCode: "RR" })).toBe(false); }); test("should return true for another valid IE with a non-zero check digit", () => { - expect(isValidIe("RR", "240000001")).toBe(true); + expect(isValidIe({ value: "240000001", stateCode: "RR" })).toBe(true); }); }); describe("RS", () => { test("should return true for a valid IE using the base rule", () => { - expect(isValidIe("RS", "0305169149")).toBe(true); + expect(isValidIe({ value: "0305169149", stateCode: "RS" })).toBe(true); }); test("should return true when digit 10 is converted to 0", () => { - expect(isValidIe("RS", "1202762660")).toBe(true); + expect(isValidIe({ value: "1202762660", stateCode: "RS" })).toBe(true); }); test("should return true when digit 11 is converted to 0", () => { - expect(isValidIe("RS", "1202762120")).toBe(true); + expect(isValidIe({ value: "1202762120", stateCode: "RS" })).toBe(true); }); test("should return false when the first verified digit is incorrect", () => { - expect(isValidIe("RS", "2007693232")).toBe(false); + expect(isValidIe({ value: "2007693232", stateCode: "RS" })).toBe(false); }); test("should return false when the length is different from 10", () => { - expect(isValidIe("RS", "02007693230")).toBe(false); + expect(isValidIe({ value: "02007693230", stateCode: "RS" })).toBe(false); }); test("should return false when the length is 11, even though the verifier digit still sits at the valid position", () => { - expect(isValidIe("RS", "03051691499")).toBe(false); + expect(isValidIe({ value: "03051691499", stateCode: "RS" })).toBe(false); }); }); describe("SC", () => { test("should return true for a valid IE using the base rule", () => { - expect(isValidIe("SC", "330430572")).toBe(true); + expect(isValidIe({ value: "330430572", stateCode: "SC" })).toBe(true); }); }); describe("SE", () => { test("should return true for a valid IE using the base rule", () => { - expect(isValidIe("SE", "017682606")).toBe(true); + expect(isValidIe({ value: "017682606", stateCode: "SE" })).toBe(true); }); }); describe("SP", () => { test("should return true for a valid IE using the base rule", () => { - expect(isValidIe("SP", "110042490114")).toBe(true); + expect(isValidIe({ value: "110042490114", stateCode: "SP" })).toBe(true); }); test("should return true for a produtor rural IE (P0MMMSSSSD000), including with formatting characters and lowercase prefix", () => { - expect(isValidIe("SP", "P011004243002")).toBe(true); - expect(isValidIe("SP", "P-01100424.3/002")).toBe(true); - expect(isValidIe("SP", "p011004243002")).toBe(true); + expect(isValidIe({ value: "P011004243002", stateCode: "SP" })).toBe(true); + expect(isValidIe({ value: "P-01100424.3/002", stateCode: "SP" })).toBe(true); + expect(isValidIe({ value: "p011004243002", stateCode: "SP" })).toBe(true); }); test("should return false when the length is bigger than 12", () => { - expect(isValidIe("SP", "1110042494114")).toBe(false); + expect(isValidIe({ value: "1110042494114", stateCode: "SP" })).toBe(false); }); test("should return false when the second verified digit is incorrect", () => { - expect(isValidIe("SP", "110042490113")).toBe(false); + expect(isValidIe({ value: "110042490113", stateCode: "SP" })).toBe(false); }); test("should return false when the first verified digit is incorrect", () => { - expect(isValidIe("SP", "110042498113")).toBe(false); + expect(isValidIe({ value: "110042498113", stateCode: "SP" })).toBe(false); }); test("should return false for a produtor rural IE with an incorrect verified digit", () => { - expect(isValidIe("SP", "P011004244002")).toBe(false); + expect(isValidIe({ value: "P011004244002", stateCode: "SP" })).toBe(false); }); test("should return false for a produtor rural IE with a length different from 13", () => { - expect(isValidIe("SP", "P01100424300")).toBe(false); + expect(isValidIe({ value: "P01100424300", stateCode: "SP" })).toBe(false); }); test("should return false when a letter appears in a position that must be a digit", () => { - expect(isValidIe("SP", "11004249011A")).toBe(false); + expect(isValidIe({ value: "11004249011A", stateCode: "SP" })).toBe(false); }); test("should return false when a company IE has an extra trailing digit, even though the first twelve digits alone would form a valid checksum", () => { - expect(isValidIe("SP", "1100424901149")).toBe(false); + expect(isValidIe({ value: "1100424901149", stateCode: "SP" })).toBe(false); }); test("should return false when a 'P' followed by twelve digits appears at the end of a longer string, instead of at the very start", () => { - expect(isValidIe("SP", "0011004243P000000000000")).toBe(false); + expect(isValidIe({ value: "0011004243P000000000000", stateCode: "SP" })).toBe(false); }); test("should return false when a produtor rural IE has an extra trailing digit, even though the verifier digit still sits at the valid position", () => { - expect(isValidIe("SP", "P0110042430029")).toBe(false); + expect(isValidIe({ value: "P0110042430029", stateCode: "SP" })).toBe(false); }); }); describe("TO", () => { test("should return true for a valid IE using the old base rule", () => { - expect(isValidIe("TO", "01027737427")).toBe(true); + expect(isValidIe({ value: "01027737427", stateCode: "TO" })).toBe(true); }); test("should return true for a valid IE using the new base rule", () => { - expect(isValidIe("TO", "294467696")).toBe(true); + expect(isValidIe({ value: "294467696", stateCode: "TO" })).toBe(true); }); test("should return true when the digit is zero", () => { - expect(isValidIe("TO", "294150870")).toBe(true); + expect(isValidIe({ value: "294150870", stateCode: "TO" })).toBe(true); }); test("should return false for an old-rule IE with an invalid category", () => { - expect(isValidIe("TO", "01047737427")).toBe(false); + expect(isValidIe({ value: "01047737427", stateCode: "TO" })).toBe(false); }); test("should return false for an 11-digit IE with an invalid type, since it must not fall back to the 9-digit rule", () => { - expect(isValidIe("TO", "29000000947")).toBe(false); + expect(isValidIe({ value: "29000000947", stateCode: "TO" })).toBe(false); }); test("should return false when the length is more than 11 digits", () => { - expect(isValidIe("TO", "099999916599")).toBe(false); + expect(isValidIe({ value: "099999916599", stateCode: "TO" })).toBe(false); }); test("should return false when the verified digit is incorrect", () => { - expect(isValidIe("TO", "99999916598")).toBe(false); + expect(isValidIe({ value: "99999916598", stateCode: "TO" })).toBe(false); }); test("should return false for a new-rule IE with an incorrect verified digit", () => { - expect(isValidIe("TO", "294467690")).toBe(false); + expect(isValidIe({ value: "294467690", stateCode: "TO" })).toBe(false); }); test("should return false when the length is 10, even though the first eight digits alone would form a valid checksum", () => { - expect(isValidIe("TO", "2944676960")).toBe(false); + expect(isValidIe({ value: "2944676960", stateCode: "TO" })).toBe(false); + }); + }); + + 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({ value: ie, stateCode })).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({ value: ie, stateCode })).toBe(true); + } }); }); describe("state code lookup", () => { test("should not resolve properties from the prototype chain", () => { // @ts-expect-error: intentionally invalid input - expect(isValidIe("constructor", "110042490114")).toBe(false); + expect(isValidIe({ value: "110042490114", stateCode: "constructor" })).toBe(false); // @ts-expect-error: intentionally invalid input - expect(isValidIe("toString", "110042490114")).toBe(false); + expect(isValidIe({ value: "110042490114", stateCode: "toString" })).toBe(false); // @ts-expect-error: intentionally invalid input - expect(isValidIe("__proto__", "110042490114")).toBe(false); + expect(isValidIe({ value: "110042490114", stateCode: "__proto__" })).toBe(false); // @ts-expect-error: intentionally invalid input - expect(isValidIe("valueOf", "110042490114")).toBe(false); + expect(isValidIe({ value: "110042490114", stateCode: "valueOf" })).toBe(false); }); test("should accept lowercase state codes", () => { // @ts-expect-error: intentionally invalid input - expect(isValidIe("sp", "110042490114")).toBe(true); + expect(isValidIe({ value: "110042490114", stateCode: "sp" })).toBe(true); // @ts-expect-error: intentionally invalid input - expect(isValidIe("go", "109161793")).toBe(true); + expect(isValidIe({ value: "109161793", stateCode: "go" })).toBe(true); }); test("should return false for missing arguments", () => { // @ts-expect-error: intentionally invalid input - expect(isValidIe(null, "110042490114")).toBe(false); + expect(isValidIe({ value: "110042490114", stateCode: null })).toBe(false); // @ts-expect-error: intentionally invalid input - expect(isValidIe(1, "110042490114")).toBe(false); + expect(isValidIe({ value: "110042490114", stateCode: 1 })).toBe(false); // @ts-expect-error: intentionally invalid input - expect(isValidIe("SP", null)).toBe(false); + expect(isValidIe({ value: null, stateCode: "SP" })).toBe(false); }); test("should return false when the sanitized IE is empty", () => { - expect(isValidIe("RJ", "----")).toBe(false); + expect(isValidIe({ value: "----", stateCode: "RJ" })).toBe(false); }); test("should return false when the IE is not a string, even though its digits alone would form a valid checksum", () => { // @ts-expect-error: intentionally invalid input - expect(isValidIe("RJ", 62_545_372)).toBe(false); + expect(isValidIe({ value: 62_545_372, stateCode: "RJ" })).toBe(false); }); test("should strip letters from a non-SP IE before validating it", () => { - expect(isValidIe("RJ", "625X45372")).toBe(true); + expect(isValidIe({ value: "625X45372", stateCode: "RJ" })).toBe(true); + }); + }); + + describe("the object form", () => { + test("should validate the registration against the state code given beside it", () => { + expect(isValidIe({ value: "110042490114", stateCode: "SP" })).toBe(true); + expect(isValidIe({ value: "P011004243002", stateCode: "SP" })).toBe(true); + expect(isValidIe({ value: "0108368143106", stateCode: "AC" })).toBe(true); + expect(isValidIe({ value: "12345", stateCode: "RJ" })).toBe(false); + expect(isValidIe({ value: "0187634580933", stateCode: "AC" })).toBe(false); + }); + + test("should return false when the object carries no usable registration or state code", () => { + expect(isValidIe({} as IsValidIeParams)).toBe(false); + expect(isValidIe({ value: "110042490114" } as IsValidIeParams)).toBe(false); + expect(isValidIe({ stateCode: "SP" } as IsValidIeParams)).toBe(false); + expect(isValidIe({ value: "110042490114", stateCode: null as unknown as StateCode })).toBe( + false, + ); + }); + + test("should return false when the first argument is neither an object nor a string", () => { + // @ts-expect-error: intentionally invalid input + expect(isValidIe()).toBe(false); + // @ts-expect-error: intentionally invalid input + expect(isValidIe(null)).toBe(false); + // @ts-expect-error: intentionally invalid input + expect(isValidIe(1)).toBe(false); + // @ts-expect-error: intentionally invalid input + expect(isValidIe(true, "110042490114")).toBe(false); + }); + }); + + /* oxlint-disable typescript/no-deprecated -- the deprecated `(stateCode, ie)` form is what this + block is about: it has to keep working, and answering exactly like the object form, until v3. */ + describe("the deprecated positional form", () => { + test("should still validate a registration given after the state code", () => { + expect(isValidIe("SP", "110042490114")).toBe(true); + expect(isValidIe("SP", "P011004243002")).toBe(true); + expect(isValidIe("AC", "0108368143106")).toBe(true); + expect(isValidIe("RJ", "12345")).toBe(false); + expect(isValidIe("AC", "0187634580933")).toBe(false); + }); + + test("should return false when the registration is missing", () => { + // @ts-expect-error: intentionally invalid input + expect(isValidIe("SP")).toBe(false); + }); + + test("should give the same verdict as the object form for every state code", () => { + const registrations = [ + "110042490114", + "P011004243002", + "0108368143106", + "109161793", + "625X45372", + "----", + "", + ]; + + for (const state of STATES) { + for (const registration of registrations) { + expect(isValidIe(state.code, registration)).toBe( + isValidIe({ value: registration, stateCode: state.code }), + ); + } + } }); }); + /* oxlint-enable typescript/no-deprecated */ describe("properties", () => { const stateCodeArbitrary = fc.constantFrom(...STATES.map((state) => state.code)); - test("should never throw and always return a boolean, for any state code and any input", () => { + test("should never throw and always return a boolean, for any pair of arguments", () => { + fc.assert( + fc.property(fc.anything(), fc.anything(), (first, second) => { + let result: unknown; + + expect(() => { + // oxlint-disable-next-line typescript/no-deprecated -- a two argument call resolves to the deprecated overload, and both forms have to survive arbitrary input + result = isValidIe(first as never, second as never); + }).not.toThrow(); + expect(typeof result).toBe("boolean"); + }), + ); + }); + + test("should never throw and always return a boolean, for an arbitrary object", () => { fc.assert( - fc.property(fc.anything(), fc.anything(), (stateCode, ie) => { + fc.property(fc.anything(), fc.anything(), (value, stateCode) => { let result: unknown; expect(() => { - result = isValidIe(stateCode as never, ie as never); + result = isValidIe({ value, stateCode } as never); }).not.toThrow(); expect(typeof result).toBe("boolean"); }), @@ -818,8 +959,17 @@ describe("isValidIe", () => { test("should never throw and always return a boolean, for every known state code and arbitrary text", () => { fc.assert( - fc.property(stateCodeArbitrary, fc.string({ unit: "grapheme" }), (stateCode, ie) => { - expect(typeof isValidIe(stateCode, ie)).toBe("boolean"); + fc.property(stateCodeArbitrary, fc.string({ unit: "grapheme" }), (stateCode, value) => { + expect(typeof isValidIe({ value, stateCode })).toBe("boolean"); + }), + ); + }); + + test("should answer the object form exactly like the deprecated positional one", () => { + fc.assert( + fc.property(stateCodeArbitrary, fc.string({ unit: "grapheme" }), (stateCode, value) => { + // oxlint-disable-next-line typescript/no-deprecated -- the deprecated form is one half of the equivalence under test + expect(isValidIe({ value, stateCode })).toBe(isValidIe(stateCode, value)); }), ); }); @@ -830,18 +980,35 @@ describe("isValidIe", () => { .filter((code) => !STATES.some((state) => state.code === code.toUpperCase())); fc.assert( - fc.property(unknownStateCodeArbitrary, fc.string(), (stateCode, ie) => { - expect(isValidIe(stateCode as never, ie)).toBe(false); + fc.property(unknownStateCodeArbitrary, fc.string(), (stateCode, value) => { + expect(isValidIe({ value, stateCode: stateCode as never })).toBe(false); }), ); }); }); }); +/* oxlint-disable typescript/no-deprecated -- a bare `isValidIe` reference resolves to its deprecated + overload, and this block pins both overloads of the signature on purpose. */ describe("isValidIe types", () => { - test("should take a StateCode and a string and return a boolean", () => { - expectTypeOf(isValidIe).parameter(0).toEqualTypeOf(); - expectTypeOf(isValidIe).parameter(1).toEqualTypeOf(); - expectTypeOf(isValidIe).returns.toEqualTypeOf(); + test("should take a single object carrying the registration and the state code", () => { + const objectForm: (params: IsValidIeParams) => boolean = isValidIe; + + expectTypeOf(objectForm).parameter(0).toEqualTypeOf(); + expectTypeOf(objectForm).returns.toEqualTypeOf(); + }); + + test("should require a registration and a state code in the parameters", () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + test("should still take a StateCode and a string, the deprecated form", () => { + const deprecatedForm: (stateCode: StateCode, ie: string) => boolean = isValidIe; + + expectTypeOf(deprecatedForm).parameter(0).toEqualTypeOf(); + expectTypeOf(deprecatedForm).parameter(1).toEqualTypeOf(); + expectTypeOf(deprecatedForm).returns.toEqualTypeOf(); }); }); +/* oxlint-enable typescript/no-deprecated */ diff --git a/src/is-valid-ie/is-valid-ie.ts b/src/is-valid-ie/is-valid-ie.ts index 3d9f46fea..ca7871042 100644 --- a/src/is-valid-ie/is-valid-ie.ts +++ b/src/is-valid-ie/is-valid-ie.ts @@ -1,7 +1,10 @@ import { type StateCode } from "../_internals/constants/states"; +import { isNullish } from "../_internals/is-nullish/is-nullish"; +import { mod10 } from "../_internals/mod10/mod10"; import { sanitizeToAlphanumeric } from "../_internals/sanitize-to-alphanumeric/sanitize-to-alphanumeric"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; import { + AL_PREFIXES, BA_MOD_10_DIGITS, GO_DUAL_DIGIT_IE, GO_PREFIXES, @@ -15,6 +18,16 @@ import { TO_TYPES, } from "./constants"; +export type { StateCode } from "../_internals/constants/states"; + +/** The parameters `isValidIe` takes: the registration and the state whose rule it is checked against. */ +export type IsValidIeParams = { + /** The inscrição estadual to validate. */ + value: string; + /** The two letter state code the registration belongs to, e.g. `"SP"`. Case insensitive. */ + stateCode: StateCode; +}; + type IeValidator = (ie: string) => boolean; const checkLength = (ie: string, length: number | number[]): boolean => { @@ -25,8 +38,6 @@ const checkLength = (ie: string, length: number | number[]): boolean => { const startsWithAny = (ie: string, prefixes: readonly string[]): boolean => prefixes.some((prefix) => ie.startsWith(prefix)); -const startsWith = (ie: string, prefix: string): boolean => startsWithAny(ie, [prefix]); - type WeightedSumParams = { source: string; length: number; @@ -82,9 +93,15 @@ const calcDFDigit = (body: string): number => { const SP_RURAL_PATTERN = /^P\d{12}$/; const SP_COMPANY_PATTERN = /^\d{12}$/; -const validateAC: IeValidator = (ie: string) => { +/** + * The 13 digit rule AC and DF share, each under its own prefix. + * @param {string} ie - The sanitized registration. + * @param {string} prefix - The two digits the state's registrations start with. + * @returns {boolean} True when the registration follows the rule under that prefix. + */ +const validateAcDfRule = (ie: string, prefix: string): boolean => { if (!checkLength(ie, 13)) return false; - if (!startsWith(ie, "01")) return false; + if (!ie.startsWith(prefix)) return false; const body = ie.slice(0, 11); const firstDig = calcDFDigit(body); @@ -96,37 +113,19 @@ const validateAC: IeValidator = (ie: string) => { ); }; -const validateAL: IeValidator = (ie: string) => { - if (!checkLength(ie, 9)) return false; - if (!startsWith(ie, "24")) return false; - - let weight = 9; - const position = 8; - let sum = 0; - - for (let i = 0; i < position; i++) { - // Stryker disable next-line ArithmeticOperator: charCodeAt(i)+48 shifts each digit by 96; with weights 9..2 (summing to 44) the total shift is 96*44=4224=384*11, a multiple of 11, so the mod-11 result is unaffected. - const digit = ie.charCodeAt(i) - 48; - sum += digit * weight; - weight--; - } - - const product = sum * 10; - let digit = product - Math.floor(product / 11) * 11; - if (digit >= 10) { - digit = 0; - } +const validateAC: IeValidator = (ie) => validateAcDfRule(ie, "01"); - return digit === Number.parseInt(ie.charAt(position), 10); -}; +// AL writes its rule as the weighted sum times ten, modulo eleven, with a ten mapped back to 0, +// which is the complement the shared modulus 11 rule takes: both give 0 for a remainder of 0 or +// 1 and `11 - remainder` for every other one. +const validateAL: IeValidator = (ie) => validateMod11Ie(ie, AL_PREFIXES); const validateAP: IeValidator = (ie: string) => { if (!checkLength(ie, 9)) return false; - if (!startsWith(ie, "03")) return false; + if (!ie.startsWith("03")) return false; const length = ie.length; const position = length - 1; - let weight = length; const body = ie.slice(0, position); const bodyInt = Number.parseInt(body, 10); let p = 0; @@ -139,13 +138,7 @@ const validateAP: IeValidator = (ie: string) => { d = 1; } - let sum = p; - for (let i = 0; i < body.length; i++) { - // Stryker disable next-line ArithmeticOperator: charCodeAt(i)+48 shifts each digit by 96; with weights 9..2 (summing to 44) the total shift is 96*44=4224=384*11, a multiple of 11, so the mod-11 result is unaffected. - const digit = ie.charCodeAt(i) - 48; - sum += digit * weight; - weight--; - } + const sum = p + calcWeightedSum({ source: ie, length: body.length, startWeight: length }); let dig = 11 - (sum % 11); if (dig === 10) { @@ -159,8 +152,6 @@ const validateAP: IeValidator = (ie: string) => { return dig === Number.parseInt(ie.charAt(position), 10); }; -const validateAM: IeValidator = (ie) => validateMod11Ie(ie); - const validateBA: IeValidator = (ie: string) => { if (!checkLength(ie, [8, 9])) return false; @@ -190,25 +181,7 @@ const validateBA: IeValidator = (ie: string) => { ); }; -const validateCE: IeValidator = (ie) => validateMod11Ie(ie); - -const validateDF: IeValidator = (ie: string) => { - if (!checkLength(ie, 13)) return false; - if (!startsWith(ie, "07")) return false; - - const length = ie.length; - const body = ie.slice(0, length - 2); - - const firstDig = calcDFDigit(body); - const secondDig = calcDFDigit(body + firstDig); - - return ( - Number.parseInt(ie.charAt(length - 2), 10) === firstDig && - Number.parseInt(ie.charAt(length - 1), 10) === secondDig - ); -}; - -const validateES: IeValidator = (ie) => validateMod11Ie(ie); +const validateDF: IeValidator = (ie) => validateAcDfRule(ie, "07"); const validateGO: IeValidator = (ie: string) => { if (!checkLength(ie, 9)) return false; @@ -245,38 +218,18 @@ const validateMG: IeValidator = (ie: string) => { const body = ie.slice(0, 11); const bodyWithZero = `${body.slice(0, 3)}0${body.slice(3)}`; - let concat = ""; - for (let i = 0; i < bodyWithZero.length; i++) { - const digit = bodyWithZero.charCodeAt(i) - 48; - const weight = i % 2 === 1 ? 2 : 1; - concat += String(digit * weight); - } - - let sum = 0; - for (let i = 0; i < concat.length; i++) { - sum += concat.charCodeAt(i) - 48; - } + // The first digit doubles every second character from the right and adds the digits of each + // product, the modulus 10 rule `mod10` implements. + const firstDig = mod10(bodyWithZero); - const lastCharInt = sum % 10; - const firstDig = lastCharInt === 0 ? 0 : 10 - lastCharInt; - - let weight = 3; - let sum2 = 0; const bodyWithFirst = body + firstDig; - for (let i = 0; i < bodyWithFirst.length; i++) { - const digit = bodyWithFirst.charCodeAt(i) - 48; - sum2 += digit * weight; - weight--; - if (weight === 1) { - weight = 11; - } - } - - const rest = sum2 % 11; - let secondDig = 11 - rest; - if (secondDig >= 10) { - secondDig = 0; - } + const secondSum = calcWeightedSum({ + source: bodyWithFirst, + length: bodyWithFirst.length, + startWeight: 3, + wrapTo: 11, + }); + const secondDig = calcMod11CheckDigit(secondSum); return ( Number.parseInt(ie.charAt(11), 10) === firstDig && @@ -298,8 +251,6 @@ const validateMS: IeValidator = (ie) => validateMod11Ie(ie, MS_PREFIXES); const validatePA: IeValidator = (ie) => validateMod11Ie(ie, PA_PREFIXES); -const validatePB: IeValidator = (ie) => validateMod11Ie(ie); - const validatePE: IeValidator = (ie: string) => { if (!checkLength(ie, 9)) return false; @@ -325,8 +276,6 @@ const validatePE: IeValidator = (ie: string) => { ); }; -const validatePI: IeValidator = (ie) => validateMod11Ie(ie); - const validatePR: IeValidator = (ie: string) => { if (!checkLength(ie, 10)) return false; @@ -366,7 +315,7 @@ const validateRJ: IeValidator = (ie: string) => { const validateRN: IeValidator = (ie: string) => { if (!checkLength(ie, [9, 10])) return false; - if (!startsWith(ie, "20")) return false; + if (!ie.startsWith("20")) return false; const length = ie.length; const position = length - 1; @@ -397,7 +346,7 @@ const validateRO: IeValidator = (ie: string) => { const validateRR: IeValidator = (ie: string) => { if (!checkLength(ie, 9)) return false; - if (!startsWith(ie, "24")) return false; + if (!ie.startsWith("24")) return false; let weight = 1; let sum = 0; @@ -424,10 +373,6 @@ const validateRS: IeValidator = (ie: string) => { return Number.parseInt(ie.charAt(9), 10) === dig; }; -const validateSC: IeValidator = (ie) => validateMod11Ie(ie); - -const validateSE: IeValidator = (ie) => validateMod11Ie(ie); - const calcSPDigit = (body: string, weights: readonly number[]): number => { let sum = 0; @@ -476,32 +421,48 @@ const IE_VALIDATORS: Record = { AC: validateAC, AL: validateAL, AP: validateAP, - AM: validateAM, + AM: validateMod11Ie, BA: validateBA, - CE: validateCE, + CE: validateMod11Ie, DF: validateDF, - ES: validateES, + ES: validateMod11Ie, GO: validateGO, MA: validateMA, MG: validateMG, MT: validateMT, MS: validateMS, PA: validatePA, - PB: validatePB, + PB: validateMod11Ie, PE: validatePE, - PI: validatePI, + PI: validateMod11Ie, PR: validatePR, RJ: validateRJ, RN: validateRN, RO: validateRO, RR: validateRR, RS: validateRS, - SC: validateSC, - SE: validateSE, + SC: validateMod11Ie, + SE: validateMod11Ie, SP: validateSP, TO: validateTO, } satisfies Record; +const validateIe = (stateCode: unknown, value: unknown): boolean => { + if (typeof stateCode !== "string") return false; + if (typeof value !== "string") return false; + + const normalizedStateCode = stateCode.toUpperCase(); + + const validator = Object.hasOwn(IE_VALIDATORS, normalizedStateCode) + ? IE_VALIDATORS[normalizedStateCode] + : undefined; + if (!validator) return false; + + const sanitize = normalizedStateCode === "SP" ? sanitizeToAlphanumeric : sanitizeToDigits; + + return validator(sanitize(value)); +}; + /** * Validates a Brazilian state tax registration number (IE). * @@ -518,20 +479,33 @@ 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 + * The state can also be passed first and the registration second, `isValidIe('SP', '110042490114')`, + * the 2.3.0 form, which still works and is deprecated. The two forms are told apart by the first + * argument: an object is the parameters of the current form, a string the state code of the + * deprecated one, and anything else returns false. + * + * @param {IsValidIeParams} params - The registration to validate and the state to validate it against + * @param {string} params.value - The state registration number to validate + * @param {StateCode} params.stateCode - The state abbreviation (e.g., 'SP', 'RJ', 'MG') * @returns {boolean} True if the state registration number is valid, false otherwise * * @example * ```typescript - * isValidIe('SP', '110042490114'); // true - * isValidIe('SP', 'P011004243002'); // true - * isValidIe('RJ', '12345'); // false + * isValidIe({ value: '110042490114', stateCode: 'SP' }); // true + * isValidIe({ value: 'P011004243002', stateCode: 'SP' }); // true + * isValidIe({ value: '12345', stateCode: 'RJ' }); // false + * isValidIe({ value: '109161793', stateCode: 'go' as StateCode }); // true (case-insensitive) * ``` * * @see Official: http://www.sintegra.gov.br/insc_est.html @@ -569,22 +543,30 @@ 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. */ -export const isValidIe = (stateCode: StateCode, ie: string): boolean => { - if (!stateCode || typeof stateCode !== "string") return false; - if (typeof ie !== "string") return false; - - const normalizedStateCode = stateCode.toUpperCase(); - - const validator = Object.hasOwn(IE_VALIDATORS, normalizedStateCode) - ? IE_VALIDATORS[normalizedStateCode] - : undefined; - if (!validator) return false; - - const sanitize = normalizedStateCode === "SP" ? sanitizeToAlphanumeric : sanitizeToDigits; - const value = sanitize(ie); - - return validator(value); -}; +export function isValidIe(params: IsValidIeParams): boolean; +/** + * Validates a Brazilian state tax registration number (IE) with the state given first. See the + * overload taking the parameters object for the full documentation. + * + * @param {StateCode} stateCode - The state abbreviation (e.g., 'SP', 'RJ', 'MG') + * @param {string} ie - The state registration number to validate + * @returns {boolean} True if the state registration number is valid, false otherwise + * + * @deprecated Use the object form, `isValidIe({ value, stateCode })`. + */ +export function isValidIe(stateCode: StateCode, ie: string): boolean; +export function isValidIe(paramsOrStateCode: IsValidIeParams | StateCode, ie?: string): boolean { + // The two call forms are told apart by the first argument alone: a string is the state code of + // the deprecated `(stateCode, ie)` form, anything else is read as the parameters object of the + // current one (a primitive has no `stateCode`, so it fails the validation like any bad input). + if (typeof paramsOrStateCode === "string") return validateIe(paramsOrStateCode, ie); + if (isNullish(paramsOrStateCode)) return false; + + return validateIe(paramsOrStateCode.stateCode, paramsOrStateCode.value); +} diff --git a/src/is-valid-legal-nature/constants.ts b/src/is-valid-legal-nature/constants.ts index e83e24a90..69ca734b3 100644 --- a/src/is-valid-legal-nature/constants.ts +++ b/src/is-valid-legal-nature/constants.ts @@ -3,12 +3,19 @@ * * 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 a past revision + * retired, mapped to the code they correspond to today by `LEGACY_LEGAL_NATURE` and still + * accepted because they keep appearing in records filed while they were in force. 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 +121,37 @@ export const LEGAL_NATURE: Record = { "5002": "Organização Internacional e Outras Instituições Extraterritoriais", }; -export const MASK_REGEX = /[-.\s]/g; +/** + * The code each legacy legal nature code corresponds to today, or `null` when the revision that + * retired it published no successor, indexed by the legacy code. + * + * Generated by `node ./scripts/legal-natures.ts`. Do not edit by hand. + * + * The mapping is the one the CONCLA correspondence spreadsheets publish. 2076 is the 2003 spelling + * of the code the 2003.1 revision renumbered to 2070, under the same denomination; 2208 (Empresa + * Binacional Itaipu) became 2275 (Empresa Binacional); 3042 (Organização Social) became 3069 + * (Fundação Privada), and the 2014 revision later created 3301 (Organização Social (OS)), where an + * entity qualified as one is classified today; 3093 became 3999; and 5002 was opened into 5010, + * 5029 and 5037, with 5010 published as its correspondence. The other three have none: 2100 is + * marked "categoria extinta", 3050 (Oscip) has an empty correspondence because an Oscip is + * classified by the form it takes (3999 or 3069), and 3123 (Partido Político), still in the 2009 + * table, was dropped by the 2014 one, which split it into 3255, 3263 and 3271 without publishing a + * correspondence. + * + * The CONCLA pages sit behind a bot filter and answer HTTP 403 to every non-browser client, so + * they have to be opened in a browser; the spreadsheets next to them are served normally. + * + * @see Official: https://concla.ibge.gov.br/classificacoes/correspondencias/natureza-juridica.html + * @see Official: https://concla.ibge.gov.br/images/concla/documentacao/correspTNJ2003(1)-2009.xls + * @see Official: https://concla.ibge.gov.br/images/concla/documentacao/correspTNJ1995-2002-2003.xls + */ +export const LEGACY_LEGAL_NATURE: Record = { + "2076": "2070", + "2100": null, + "2208": "2275", + "3042": "3069", + "3050": null, + "3093": "3999", + "3123": null, + "5002": "5010", +}; diff --git a/src/is-valid-legal-nature/is-valid-legal-nature.test.ts b/src/is-valid-legal-nature/is-valid-legal-nature.test.ts index 1dc38922c..5c0823bc9 100644 --- a/src/is-valid-legal-nature/is-valid-legal-nature.test.ts +++ b/src/is-valid-legal-nature/is-valid-legal-nature.test.ts @@ -3,7 +3,7 @@ import * as fc from "fast-check"; import { anyValue, maskedValues } from "../_internals/test/arbitraries"; import { expectAccepted, expectAlwaysReturnsType } from "../_internals/test/properties"; import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; -import { LEGAL_NATURE } from "./constants"; +import { LEGACY_LEGAL_NATURE, LEGAL_NATURE } from "./constants"; import { isValidLegalNature } from "./is-valid-legal-nature"; describe("isValidLegalNature", () => { @@ -31,6 +31,13 @@ describe("isValidLegalNature", () => { expect(isValidLegalNature("2076")).toBe(true); }); + it("should accept every legacy code, the ones getLegalNatures leaves out by default", () => { + for (const code of Object.keys(LEGACY_LEGAL_NATURE)) { + expect(isValidLegalNature(code)).toBe(true); + expect(isValidLegalNature(`${code.slice(0, 3)}-${code.slice(3)}`)).toBe(true); + } + }); + it("should reject codes with a length different from 4", () => { expect(isValidLegalNature("206")).toBe(false); expect(isValidLegalNature("20620")).toBe(false); 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 87caded07..9fce894e7 100644 --- a/src/is-valid-legal-nature/is-valid-legal-nature.ts +++ b/src/is-valid-legal-nature/is-valid-legal-nature.ts @@ -1,4 +1,5 @@ -import { LEGAL_NATURE, MASK_REGEX } from "./constants"; +import { SEPARATORS_REGEX } from "../_internals/constants/separators"; +import { LEGAL_NATURE } from "./constants"; /** * Validates if a Brazilian legal nature (natureza jurídica) code exists. @@ -7,6 +8,11 @@ import { LEGAL_NATURE, MASK_REGEX } from "./constants"; * digits. Any other character makes the value invalid, so `"2062a"` is rejected instead of * being read as `"2062"`. * + * The 8 codes a past revision of the CONCLA table retired are accepted alongside the 92 in force, + * because they still appear in records filed while they were in force. Use `getLegalNature` to + * tell the two apart: a retired code comes back with `legacy: true` and the `currentCode` it + * corresponds to today. + * * @param {string} code - The legal nature code to be validated, with or without formatting. * @returns {boolean} True when the code is a known 4 digit legal nature, false otherwise. * @@ -14,17 +20,22 @@ import { LEGAL_NATURE, MASK_REGEX } from "./constants"; * ```typescript * isValidLegalNature("2062"); // true * isValidLegalNature("206-2"); // true + * isValidLegalNature("2208"); // true (retired by a past revision, still accepted) * isValidLegalNature("2062a"); // false * 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 */ export const isValidLegalNature = (code: string): boolean => { if (typeof code !== "string") return false; - const normalized = code.replace(MASK_REGEX, ""); + const normalized = code.replace(SEPARATORS_REGEX, ""); return Object.hasOwn(LEGAL_NATURE, normalized); }; 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 09028a7d0..3882bb9cd 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 8e85f36fa..63835132b 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 02c09361c..f2a6e1a86 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 3ed92c842..d6afbdf7a 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 e443f8450..2c0444449 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 9a8859f7c..ab08009cc 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 ebf3790d2..e2b744ce2 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 b0edffe79..8d837a916 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 7b8fb3a34..6385db454 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 188c91190..1721497f3 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 c6b700855..49b8051ef 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 a0ae3ea33..363be4fe8 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-pis/constants.ts b/src/is-valid-pis/constants.ts deleted file mode 100644 index 3be427519..000000000 --- a/src/is-valid-pis/constants.ts +++ /dev/null @@ -1,12 +0,0 @@ -export const RESERVED_NUMBERS = [ - "00000000000", - "11111111111", - "22222222222", - "33333333333", - "44444444444", - "55555555555", - "66666666666", - "77777777777", - "88888888888", - "99999999999", -]; diff --git a/src/is-valid-pis/is-valid-pis.test.ts b/src/is-valid-pis/is-valid-pis.test.ts index 7df1bdd61..b379fe1bd 100644 --- a/src/is-valid-pis/is-valid-pis.test.ts +++ b/src/is-valid-pis/is-valid-pis.test.ts @@ -5,13 +5,25 @@ import { anyValue, digitsOfOtherLength, maskSeparators } from "../_internals/tes import { expectAlwaysReturnsType, expectRejected } from "../_internals/test/properties"; import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; import { generatePis } from "../generate-pis/generate-pis"; -import { RESERVED_NUMBERS } from "./constants"; import { isValidPis } from "./is-valid-pis"; +const REPEATED_DIGITS = [ + "00000000000", + "11111111111", + "22222222222", + "33333333333", + "44444444444", + "55555555555", + "66666666666", + "77777777777", + "88888888888", + "99999999999", +]; + describe("isValidPis", () => { describe("should return false", () => { - test("when it is on the RESERVED_NUMBERS", () => { - for (const pis of RESERVED_NUMBERS) { + test("when every digit is the same", () => { + for (const pis of REPEATED_DIGITS) { expect(isValidPis(pis)).toBe(false); } }); diff --git a/src/is-valid-pis/is-valid-pis.ts b/src/is-valid-pis/is-valid-pis.ts index ae596f253..ddd493ee0 100644 --- a/src/is-valid-pis/is-valid-pis.ts +++ b/src/is-valid-pis/is-valid-pis.ts @@ -1,7 +1,7 @@ -import { PIS_LENGTH, PIS_WEIGHTS } from "../_internals/constants/pis"; -import { generateChecksum } from "../_internals/generate-checksum/generate-checksum"; +import { calculatePisCheckDigit } from "../_internals/calculate-pis-check-digit/calculate-pis-check-digit"; +import { PIS_LENGTH } from "../_internals/constants/pis"; +import { isRepeatedDigits } from "../_internals/is-repeated-digits/is-repeated-digits"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; -import { RESERVED_NUMBERS } from "./constants"; /** * Validates a Brazilian PIS (Programa de Integração Social) number. @@ -38,15 +38,7 @@ export const isValidPis = (pis: string): boolean => { if (digits.length !== PIS_LENGTH) return false; - if (RESERVED_NUMBERS.includes(digits)) return false; + if (isRepeatedDigits(digits)) return false; - const base = digits.slice(0, PIS_LENGTH - 1); - const checkDigit = digits.charCodeAt(PIS_LENGTH - 1) - 48; - - const weightedChecksum = generateChecksum({ base, weight: PIS_WEIGHTS }); - const calculatedDigit = 11 - (weightedChecksum % 11); - - const finalDigit = calculatedDigit >= 10 ? 0 : calculatedDigit; - - return checkDigit === finalDigit; + return digits.charCodeAt(PIS_LENGTH - 1) - 48 === calculatePisCheckDigit(digits); }; 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 01d560ba6..eca5f1b4e 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 a49b0935c..547b9bef4 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 f32035420..a07623165 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 21d4482c9..8dc13d5b9 100644 --- a/src/is-valid-processo-juridico/constants.ts +++ b/src/is-valid-processo-juridico/constants.ts @@ -1,4 +1,5 @@ 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 2b5a49412..3c369f6ce 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"; @@ -35,6 +38,12 @@ describe("isValidProcessoJuridico", () => { expect(isValidProcessoJuridico("00020802520125150050")).toBe(false); }); + test("when a mask character leads or trails an otherwise valid value", () => { + expect(isValidProcessoJuridico("-00020802520125150049")).toBe(false); + expect(isValidProcessoJuridico("00020802520125150049.")).toBe(false); + expect(isValidProcessoJuridico(".0002080-25.2012.5.15.0049-")).toBe(false); + }); + test("when a letter is attached to the digits", () => { expect(isValidProcessoJuridico("ab00020802520125150049")).toBe(false); expect(isValidProcessoJuridico("00020802520125150049ab")).toBe(false); @@ -47,6 +56,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 +103,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 +177,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 c4d87a983..e8cda1d84 100644 --- a/src/is-valid-processo-juridico/is-valid-processo-juridico.ts +++ b/src/is-valid-processo-juridico/is-valid-processo-juridico.ts @@ -1,12 +1,14 @@ +import { calculateProcessoJuridicoCheckDigits } from "../_internals/calculate-processo-juridico-check-digits/calculate-processo-juridico-check-digits"; +import { PROCESSO_JURIDICO_TRIBUNALS } from "../_internals/constants/processo-juridico"; +import { SEPARATORS_REGEX } from "../_internals/constants/separators"; import { CHECK_DIGIT_LENGTH, CHECK_DIGIT_START_POSITION, - MOD_97_10_QUOTIENT, - MOD_97_10_SUM, + COURT_POSITION, + TRIBUNAL_LENGTH, + TRIBUNAL_START_POSITION, } from "./constants"; -const SEPARATORS_REGEX = /[\s.-]/g; - const FORMAT_REGEX = /^\d{7}[\s.-]*\d{2}[\s.-]*\d{4}[\s.-]*\d[\s.-]*\d{2}[\s.-]*\d{4}$/; const verifyCheckDigit = (value: string): boolean => { @@ -19,28 +21,29 @@ const verifyCheckDigit = (value: string): boolean => { value.slice(0, CHECK_DIGIT_START_POSITION) + value.slice(CHECK_DIGIT_START_POSITION + CHECK_DIGIT_LENGTH); - let digits1to11 = 0; - for (let i = 0; i < 11; i++) { - digits1to11 += (withoutCheck.charCodeAt(i) - 48) * 10 ** (10 - i); - } - const firstRemainder = digits1to11 % MOD_97_10_QUOTIENT; - - let digits12to18 = 0; - for (let i = 11; i < 18; i++) { - digits12to18 += (withoutCheck.charCodeAt(i) - 48) * 10 ** (6 - (i - 11)); - } + return calculateProcessoJuridicoCheckDigits(withoutCheck) === verificationDigits; +}; - const secondRemainder = - (firstRemainder * 1_000_000_000 + digits12to18 * 100) % MOD_97_10_QUOTIENT; +const verifyCourtAndTribunal = (value: string): boolean => { + const tribunals = PROCESSO_JURIDICO_TRIBUNALS.get(Number(value.charAt(COURT_POSITION))); - const verifier = MOD_97_10_SUM - secondRemainder; + if (tribunals === undefined) return false; - return verifier === verificationDigits; + 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 +56,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 +70,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 95b759b03..7af5c522b 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 422267163..cea859a8a 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 @@ -5,7 +5,7 @@ import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime import { type RegistroProfissionalCouncil } from "./constants"; import { isValidRegistroProfissional, - type IsValidRegistroProfissionalOptions, + type IsValidRegistroProfissionalParams, } from "./is-valid-registro-profissional"; const STATE_CODES = DATA.map((state) => state.code); @@ -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 IsValidRegistroProfissionalParams); expect(typeof result).toBe("boolean"); }), @@ -219,15 +321,18 @@ 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) - .toEqualTypeOf(); + .parameter(0) + .toEqualTypeOf(); + expectTypeOf(isValidRegistroProfissional).parameters.toEqualTypeOf< + [IsValidRegistroProfissionalParams] + >(); + expectTypeOf().toEqualTypeOf(); expectTypeOf< - IsValidRegistroProfissionalOptions["council"] + IsValidRegistroProfissionalParams["council"] >().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf< + expectTypeOf().toEqualTypeOf< StateCode | undefined >(); expectTypeOf().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 d6fbd1936..73c4f08ba 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,6 @@ -import { DATA, type StateCode } from "../_internals/constants/states"; +import { type StateCode } from "../_internals/constants/states"; +import { isNullish } from "../_internals/is-nullish/is-nullish"; +import { isStateCode } from "../_internals/is-state-code/is-state-code"; import { sanitizeToAlphanumeric } from "../_internals/sanitize-to-alphanumeric/sanitize-to-alphanumeric"; import { CRC_REGEX, @@ -10,8 +12,12 @@ import { type RegistroProfissionalCouncil, } from "./constants"; -/** The options `isValidRegistroProfissional` takes: the professional council and, optionally, the UF the registration must belong to. */ -export type IsValidRegistroProfissionalOptions = { +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 IsValidRegistroProfissionalParams = { + /** 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). */ @@ -26,8 +32,6 @@ const REGEX_BY_COUNCIL: Record = { CRC: CRC_REGEX, }; -const isKnownStateCode = (value: string): boolean => DATA.some((state) => state.code === value); - const isKnownCrpRegion = (value: string): boolean => { const region = Number(value); @@ -40,7 +44,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 +54,104 @@ 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. * - * @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"`. + * 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 {IsValidRegistroProfissionalParams} 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, -): boolean => { - if (typeof value !== "string") return false; +export const isValidRegistroProfissional = (params: IsValidRegistroProfissionalParams): boolean => { + 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 && !isStateCode(transferUf)) return false; + if (uf === undefined) return true; - if (!isKnownStateCode(uf)) return false; + if (!isStateCode(uf)) return false; - return !options.stateCode || uf === options.stateCode; + return !stateCode || uf === stateCode; }; diff --git a/src/is-valid-renavam/is-valid-renavam.test.ts b/src/is-valid-renavam/is-valid-renavam.test.ts index 3f0945fc1..8414bbd5b 100644 --- a/src/is-valid-renavam/is-valid-renavam.test.ts +++ b/src/is-valid-renavam/is-valid-renavam.test.ts @@ -11,6 +11,14 @@ describe("isValidRenavam", () => { expect(isValidRenavam("")).toBe(false); }); + test("when a valid registration carries an extra digit", () => { + // The 11 digits of a valid RENAVAM, plus a twelfth: neither the 9 nor the 11 digit form, + // so it is turned down even though its first eleven digits check out. + expect(isValidRenavam("00639884962")).toBe(true); + expect(isValidRenavam("006398849620")).toBe(false); + expect(isValidRenavam("0639884962")).toBe(false); + }); + test("when it is null", () => { // @ts-expect-error: intentionally invalid input expect(isValidRenavam(null)).toBe(false); diff --git a/src/is-valid-renavam/is-valid-renavam.ts b/src/is-valid-renavam/is-valid-renavam.ts index 277b55ba0..222b87847 100644 --- a/src/is-valid-renavam/is-valid-renavam.ts +++ b/src/is-valid-renavam/is-valid-renavam.ts @@ -1,14 +1,13 @@ +import { calculateRenavamCheckDigit } from "../_internals/calculate-renavam-check-digit/calculate-renavam-check-digit"; +import { SEPARATORS_REGEX } from "../_internals/constants/separators"; import { isRepeatedDigits } from "../_internals/is-repeated-digits/is-repeated-digits"; const RENAVAM_LENGTH = 11; -const SEPARATORS_REGEX = /[\s.-]/g; +const BASE_LENGTH = 10; const FORMAT_REGEX = /^\d{9}$|^\d{11}$/; -const padLeft = (input: string, padLength: number): string => - "0".repeat(padLength - input.length) + input; - /** * Validates if a RENAVAM (Registro Nacional de Veículos Automotores) is valid. * @@ -49,32 +48,13 @@ export const isValidRenavam = (renavam: string | number): boolean => { if (!FORMAT_REGEX.test(digits)) return false; - const paddedDigits = padLeft(digits, RENAVAM_LENGTH); + const paddedDigits = digits.padStart(RENAVAM_LENGTH, "0"); 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 70610ebf6..f5c2b7efb 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 24bf5b4f4..252b2affc 100644 --- a/src/is-valid-service-phone/is-valid-service-phone.ts +++ b/src/is-valid-service-phone/is-valid-service-phone.ts @@ -6,16 +6,9 @@ import { SERVICE_PHONE_NON_GEOGRAPHIC_PREFIX_LENGTH, SERVICE_PHONE_NON_GEOGRAPHIC_PREFIXES, SERVICE_PHONE_UTILITY_CODES, - SERVICE_PHONE_UTILITY_LENGTH, } from "../_internals/constants/service-phone"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; -const NON_GEOGRAPHIC_PREFIXES: readonly string[] = SERVICE_PHONE_NON_GEOGRAPHIC_PREFIXES; - -const ABBREVIATED_ROOTS: readonly string[] = SERVICE_PHONE_ABBREVIATED_ROOTS; - -const UTILITY_CODES: readonly string[] = SERVICE_PHONE_UTILITY_CODES; - /** * Validates if a phone number is a valid Brazilian service number. * @@ -23,12 +16,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 +43,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; @@ -52,19 +53,18 @@ export const isValidServicePhone = (value: string): boolean => { const digits = sanitizeToDigits(value); if (digits.length === SERVICE_PHONE_NON_GEOGRAPHIC_LENGTH) { - return NON_GEOGRAPHIC_PREFIXES.includes( + return SERVICE_PHONE_NON_GEOGRAPHIC_PREFIXES.includes( digits.slice(0, SERVICE_PHONE_NON_GEOGRAPHIC_PREFIX_LENGTH), ); } if (digits.length === SERVICE_PHONE_ABBREVIATED_LENGTH) { - return ABBREVIATED_ROOTS.includes(digits.slice(0, SERVICE_PHONE_ABBREVIATED_ROOT_LENGTH)); - } - - // Stryker disable next-line ConditionalExpression: UTILITY_CODES.includes(digits) only ever matches an exact 3 character code, so a digits value of any other length already correctly fails this check on its own - if (digits.length === SERVICE_PHONE_UTILITY_LENGTH) { - return UTILITY_CODES.includes(digits); + return SERVICE_PHONE_ABBREVIATED_ROOTS.includes( + digits.slice(0, SERVICE_PHONE_ABBREVIATED_ROOT_LENGTH), + ); } - return false; + // Every public utility code is exactly 3 digits, so a value of any other length that reaches + // here matches none of them and is turned down by this very check. + return SERVICE_PHONE_UTILITY_CODES.includes(digits); }; diff --git a/src/is-valid-vin/constants.ts b/src/is-valid-vin/constants.ts index 35726df75..83de72057 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 bfc119a29..30bf44fc9 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 28fb5239b..ef2e8cada 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 = ""; @@ -53,7 +75,7 @@ export const isValidVin = (value: string): boolean => { const checkDigit = vin[VIN_CHECK_DIGIT_POSITION]; - const remainder = generateChecksum({ base: translitDigits, weight: [...VIN_WEIGHTS] }) % 11; + const remainder = generateChecksum({ base: translitDigits, weight: VIN_WEIGHTS }) % 11; const expected = remainder === 10 ? "X" : String(remainder); return expected === checkDigit; 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 6ffba7d41..f9bbc3e41 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 caf9008f4..f9f25f4a6 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 000000000..958958867 --- /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 000000000..2fb7c947a --- /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 000000000..10a668f7b --- /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 000000000..29915d8cf --- /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 000000000..bc6c3a0af --- /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 000000000..86eece2f5 --- /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 000000000..a063dbe1d --- /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 000000000..393f687fe --- /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 000000000..7e7149668 --- /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 1d6827902..000000000 --- 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 984367f79..611c03a7b 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 977e19d51..668928323 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 000000000..4690ce14e --- /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 000000000..aec7beeb7 --- /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 000000000..077f9cedb --- /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 000000000..04d0bc614 --- /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 000000000..00a41e6a2 --- /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 000000000..aea5a35b6 --- /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 000000000..5aac1b3c4 --- /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 000000000..deb7b3998 --- /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 000000000..bc9c49a25 --- /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-cnpj/parse-cnpj.ts b/src/parse-cnpj/parse-cnpj.ts index eb90e6dd1..10d66a331 100644 --- a/src/parse-cnpj/parse-cnpj.ts +++ b/src/parse-cnpj/parse-cnpj.ts @@ -1,20 +1,11 @@ import { CNPJ_LENGTH } from "../_internals/constants/cnpj"; import { isNullish } from "../_internals/is-nullish/is-nullish"; -import { sanitizeToAlphanumeric } from "../_internals/sanitize-to-alphanumeric/sanitize-to-alphanumeric"; -import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { sanitizeCnpj } from "../_internals/sanitize-cnpj/sanitize-cnpj"; import { type FormatCnpjOptions } from "../format-cnpj/format-cnpj"; /** Options of `parseCnpj`. */ export type ParseCnpjOptions = Pick; -const sanitize = (value: string | number, version?: FormatCnpjOptions["version"]): string => { - if (version === 2) { - return sanitizeToAlphanumeric(value); - } - - return sanitizeToDigits(value); -}; - /** * Removes CNPJ formatting characters and returns a normalized value. * @@ -34,4 +25,4 @@ const sanitize = (value: string | number, version?: FormatCnpjOptions["version"] * @see Official: https://www.gov.br/receitafederal/pt-br/acesso-a-informacao/acoes-e-programas/programas-e-atividades/cnpj-alfanumerico */ export const parseCnpj = (value: string | number, options?: ParseCnpjOptions): string => - isNullish(value) ? "" : sanitize(value, options?.version).slice(0, CNPJ_LENGTH); + isNullish(value) ? "" : sanitizeCnpj(value, options?.version).slice(0, CNPJ_LENGTH); diff --git a/src/parse-cns/constants.ts b/src/parse-cns/constants.ts new file mode 100644 index 000000000..f2b4dcc91 --- /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 000000000..614788971 --- /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 000000000..3700d8f47 --- /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 00d8079dc..4955026fe 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 7b0bbf423..b39b2df44 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 b115adac4..25889303c 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 9c7e554aa..add0fcf98 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 a6646406a..3043b0239 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 d1745002f..e76df3551 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 000000000..aab8be375 --- /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 000000000..83e701d8b --- /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 000000000..fa25d92bf --- /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 bad695aac..815fe45fe 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 059a17fe2..9f6395c11 100644 --- a/src/parse-nfe-key/parse-nfe-key.ts +++ b/src/parse-nfe-key/parse-nfe-key.ts @@ -1,189 +1,39 @@ -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 { 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. - */ -export type NfeKeyModel = "55" | "57" | "58" | "62" | "63" | "64" | "65" | "66" | "67"; - -/** 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; -}; - -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. - * - * 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. + * Removes the formatting of a DF-e (Documento Fiscal eletrônico) access key (chave de acesso) and + * returns only digits. * - * 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 `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. * - * 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. + * 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. * - * @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]); +export const parseNfeKey = (value: string | number): string => { + // Stryker disable next-line StringLiteral: whatever replaces the prefix is stripped again by sanitizeToDigits unless it carries a digit, and the mutant's literal carries none. + const body = toStringSafe(value).trim().replace(XML_ID_PREFIX_REGEX, ""); - return parsed; + return sanitizeToDigits(body).slice(0, NFE_KEY_LENGTH); }; diff --git a/src/parse-passport/parse-passport.ts b/src/parse-passport/parse-passport.ts index 88971fb44..c05505620 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 7642be594..d50760e53 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 4d1ed719e..a0d1834a8 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 2217fa8ba..230246fe7 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 000000000..f428e7a84 --- /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 000000000..664c6fbde --- /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/stryker.config.json b/stryker.config.json index 33d9a0236..a6f745b68 100644 --- a/stryker.config.json +++ b/stryker.config.json @@ -4,6 +4,7 @@ "vitest": { "configFile": "vite.config.ts" }, + "tsconfigFile": "", "mutate": [ "src/**/*.ts", "!src/**/*.test.ts", diff --git a/vite.config.ts b/vite.config.ts index 7ca2b7367..9025502c8 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", },