diff --git a/packages/cnpj-dv/CHANGELOG.md b/packages/cnpj-dv/CHANGELOG.md
new file mode 100644
index 0000000..9296044
--- /dev/null
+++ b/packages/cnpj-dv/CHANGELOG.md
@@ -0,0 +1,15 @@
+# lacus/cnpj-dv
+
+## 1.0.0
+
+### 🚀 Stable Version Released!
+
+Utility class to calculate check digits on CNPJ (Cadastro Nacional da Pessoa Jurídica). Main features:
+
+- **Flexible input**: Accepts string or array of strings (formatted or raw).
+- **Format agnostic**: Automatically strips non-numeric characters from input.
+- **Lazy evaluation & caching**: Check digits are calculated only when accessed for the first time.
+- **Minimal dependencies**: [`lacus/utils`](https://packagist.org/packages/lacus/utils) only.
+- **Error handling**: Specific types for type, length, and invalid input scenarios (`TypeError` / `Exception` hierarchy).
+
+For detailed usage and API reference, see the [README](./README.md).
diff --git a/packages/cnpj-dv/LICENSE b/packages/cnpj-dv/LICENSE
new file mode 100644
index 0000000..1c8640a
--- /dev/null
+++ b/packages/cnpj-dv/LICENSE
@@ -0,0 +1,9 @@
+MIT License
+
+Copyright (c) 2026 Julio L. Muller
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/packages/cnpj-dv/README.md b/packages/cnpj-dv/README.md
new file mode 100644
index 0000000..f6422f3
--- /dev/null
+++ b/packages/cnpj-dv/README.md
@@ -0,0 +1,171 @@
+
+
+[](https://packagist.org/packages/lacus/cnpj-dv)
+[](https://packagist.org/packages/lacus/cnpj-dv)
+[](https://www.php.net/)
+[](https://github.com/LacusSolutions/br-utils-php/actions)
+[](https://github.com/LacusSolutions/br-utils-php)
+[](https://github.com/LacusSolutions/br-utils-php/blob/main/LICENSE)
+
+> 🚀 **Full support for the [new alphanumeric CNPJ format](https://github.com/user-attachments/files/23937961/calculodvcnpjalfanaumerico.pdf).**
+
+> 🌎 [Acessar documentação em português](https://github.com/LacusSolutions/br-utils-php/blob/main/packages/cnpj-dv/README.pt.md)
+
+A PHP utility to calculate check digits on CNPJ (Brazilian Business Tax ID).
+
+## PHP Support
+
+|  |  |  |  |
+| --- | --- | --- | --- |
+| Passing ✔ | Passing ✔ | Passing ✔ | Passing ✔ |
+
+## Features
+
+- ✅ **Alphanumeric CNPJ**: Full support for the new alphanumeric CNPJ format (introduced in 2026)
+- ✅ **Flexible input**: Accepts `string` or `array` of strings
+- ✅ **Format agnostic**: Strips non-alphanumeric characters from string input and uppercases letters
+- ✅ **Auto-expansion**: Multi-character strings in arrays are joined and parsed like a single string
+- ✅ **Input validation**: Rejects ineligible CNPJs (all-zero base ID `00000000`, all-zero branch `0000`, or 12 numeric-only repeated digits)
+- ✅ **Lazy evaluation**: Check digits are calculated only when accessed (via properties)
+- ✅ **Caching**: Calculated values are cached for subsequent access
+- ✅ **Property-style API**: `first`, `second`, `both`, `cnpj` (via magic `__get`)
+- ✅ **Minimal dependencies**: Only [`lacus/utils`](https://packagist.org/packages/lacus/utils)
+- ✅ **Error handling**: Specific types for type, length, and invalid CNPJ scenarios (`TypeError` vs `Exception` semantics)
+
+## Installation
+
+```bash
+# using Composer
+$ composer require lacus/cnpj-dv
+```
+
+## Quick Start
+
+```php
+first; // '9'
+$checkDigits->second; // '3'
+$checkDigits->both; // '93'
+$checkDigits->cnpj; // '91415732000793'
+```
+
+With alphanumeric CNPJ (new format):
+
+```php
+first; // '6'
+$checkDigits->second; // '8'
+$checkDigits->both; // '68'
+$checkDigits->cnpj; // 'MGKGMJ9X000168'
+```
+
+## Usage
+
+The main resource of this package is the class `CnpjCheckDigits`. Through an instance, you access CNPJ check-digit information:
+
+- **`__construct`**: `new CnpjCheckDigits(string|array $cnpjInput)` — 12–14 alphanumeric characters after sanitization (formatting stripped from strings; letters uppercased). Only the **first 12** characters are used as the base; if you pass 13 or 14 characters (e.g. a full CNPJ including prior check digits), characters 13–14 are **ignored** and the digits are recalculated.
+- **`first`**: First check digit (13th character of the full CNPJ). Lazy, cached.
+- **`second`**: Second check digit (14th character of the full CNPJ). Lazy, cached.
+- **`both`**: Both check digits concatenated as a string.
+- **`cnpj`**: The complete CNPJ as a string of 14 characters (12 base characters + 2 check digits).
+
+### Input formats
+
+The `CnpjCheckDigits` class accepts multiple input formats:
+
+**String input:** raw digits and/or letters, or formatted CNPJ (e.g. `91.415.732/0007-93`, `MG.KGM.J9X/0001-68`). Non-alphanumeric characters are removed; lowercase letters are uppercased.
+
+**Array of strings:** each element must be a string; values are concatenated and then parsed like a single string (e.g. `['9','1','4',…]`, `['9141','5732','0007']`, `['MG','KGM','J9X','0001']`). Non-string elements are not allowed.
+
+### Errors & exceptions handling
+
+This package uses **TypeError vs Exception** semantics: *type errors* indicate incorrect API use (e.g. wrong type); *exceptions* indicate invalid or ineligible data (e.g. invalid length or business rules). You can catch specific classes or use the abstract bases.
+
+- **CnpjCheckDigitsTypeError** (_abstract_) — base for type errors; extends PHP’s `TypeError`
+- **CnpjCheckDigitsInputTypeError** — input is not `string` or `array` of strings (or array contains a non-string element)
+- **CnpjCheckDigitsException** (_abstract_) — base for data/flow exceptions; extends `Exception`
+- **CnpjCheckDigitsInputLengthException** — sanitized length is not 12–14
+- **CnpjCheckDigitsInputInvalidException** — base ID `00000000`, branch ID `0000`, or 12 identical numeric digits (repeated-digit pattern)
+
+```php
+getMessage();
+}
+
+// Length (must be 12–14 alphanumeric characters after sanitization)
+try {
+ new CnpjCheckDigits('12345678901');
+} catch (CnpjCheckDigitsInputLengthException $e) {
+ echo $e->getMessage();
+}
+
+// Invalid (e.g. all-zero base or branch, or repeated numeric digits)
+try {
+ new CnpjCheckDigits('000000000001');
+} catch (CnpjCheckDigitsInputInvalidException $e) {
+ echo $e->getMessage();
+}
+
+// Any data exception from the package
+try {
+ // risky code
+} catch (CnpjCheckDigitsException $e) {
+ // handle
+}
+```
+
+### Other available resources
+
+- **`CNPJ_MIN_LENGTH`**: `12` — class constant `CnpjCheckDigits::CNPJ_MIN_LENGTH`, and global `Lacus\BrUtils\Cnpj\CNPJ_MIN_LENGTH` when the autoloaded `cnpj-dv.php` file is loaded.
+- **`CNPJ_MAX_LENGTH`**: `14` — class constant `CnpjCheckDigits::CNPJ_MAX_LENGTH`, and global `Lacus\BrUtils\Cnpj\CNPJ_MAX_LENGTH` when `cnpj-dv.php` is loaded.
+
+## Calculation algorithm
+
+The package computes check digits with the official Brazilian modulo-11 rules extended to alphanumeric characters:
+
+1. **Character value:** each character contributes `ord(character) − 48` (so `0`–`9` stay 0–9; letters use their ASCII offset from `0`).
+2. **Weights:** from **right to left**, multiply by weights that cycle **2, 3, 4, 5, 6, 7, 8, 9**, then repeat from 2.
+3. **First check digit (13th position):** apply steps 1–2 to the first **12** base characters; let `r = sum % 11`. The digit is `0` if `r < 2`, otherwise `11 − r`.
+4. **Second check digit (14th position):** apply steps 1–2 to the first 12 characters **plus** the first check digit; same formula for `r`.
+
+## Contribution & Support
+
+We welcome contributions! Please see our [Contributing Guidelines](https://github.com/LacusSolutions/br-utils-php/blob/main/CONTRIBUTING.md) for details. If you find this project helpful, please consider:
+
+- ⭐ Starring the repository
+- 🤝 Contributing to the codebase
+- 💡 [Suggesting new features](https://github.com/LacusSolutions/br-utils-php/issues)
+- 🐛 [Reporting bugs](https://github.com/LacusSolutions/br-utils-php/issues)
+
+## License
+
+This project is licensed under the MIT License — see the [LICENSE](https://github.com/LacusSolutions/br-utils-php/blob/main/LICENSE) file for details.
+
+## Changelog
+
+See [CHANGELOG](https://github.com/LacusSolutions/br-utils-php/blob/main/packages/cnpj-dv/CHANGELOG.md) for a list of changes and version history.
+
+---
+
+Made with ❤️ by [Lacus Solutions](https://github.com/LacusSolutions)
diff --git a/packages/cnpj-dv/README.pt.md b/packages/cnpj-dv/README.pt.md
new file mode 100644
index 0000000..2b5cee8
--- /dev/null
+++ b/packages/cnpj-dv/README.pt.md
@@ -0,0 +1,143 @@
+
+
+> 🚀 **Suporte total ao [novo formato alfanumérico de CNPJ](https://github.com/user-attachments/files/23937961/calculodvcnpjalfanaumerico.pdf).**
+
+> 🌎 [Access documentation in English](https://github.com/LacusSolutions/br-utils-php/blob/main/packages/cnpj-dv/README.md)
+
+Utilitário em PHP para calcular os dígitos verificadores de CNPJ (Cadastro Nacional da Pessoa Jurídica).
+
+## Recursos
+
+- ✅ **CNPJ alfanumérico**: Suporte completo ao novo formato alfanumérico de CNPJ (a partir de 2026)
+- ✅ **Entrada flexível**: Aceita `string` ou `array` de strings
+- ✅ **Agnóstico ao formato**: Remove caracteres não alfanuméricos da entrada em string e converte letras para maiúsculas
+- ✅ **Junção em array**: Strings com vários caracteres em arrays são concatenadas e interpretadas como uma única sequência
+- ✅ **Validação de entrada**: Rejeita CNPJs inelegíveis (base toda zero `00000000`, filial `0000`, ou 12 dígitos numéricos repetidos)
+- ✅ **Avaliação lazy**: Dígitos verificadores são calculados apenas quando acessados (via propriedades)
+- ✅ **Cache**: Valores calculados são armazenados em cache para acessos subsequentes
+- ✅ **API estilo propriedades**: `first`, `second`, `both`, `cnpj` (via `__get` mágico)
+- ✅ **Dependências mínimas**: Apenas [`lacus/utils`](https://packagist.org/packages/lacus/utils)
+- ✅ **Tratamento de erros**: Tipos específicos para tipo, tamanho e CNPJ inválido (semântica `TypeError` vs `Exception`)
+
+## Instalação
+
+```bash
+# usando Composer
+$ composer require lacus/cnpj-dv
+```
+
+## Início rápido
+
+```php
+first; // '9'
+$checkDigits->second; // '3'
+$checkDigits->both; // '93'
+$checkDigits->cnpj; // '91415732000793'
+```
+
+## Utilização
+
+O principal recurso deste pacote é a classe `CnpjCheckDigits`. Por meio da instância, você acessa as informações dos dígitos verificadores do CNPJ:
+
+- **`__construct`**: `new CnpjCheckDigits(string|array $cnpjInput)` — 12–14 caracteres alfanuméricos após a sanitização (formatação removida em strings; letras em maiúsculas). Apenas os **primeiros 12** caracteres entram como base; com 13 ou 14 caracteres (ex.: CNPJ completo com DV anteriores), os caracteres 13 e 14 são **ignorados** e os dígitos são recalculados.
+- **`first`**: Primeiro dígito verificador (13º caractere do CNPJ completo). Lazy, em cache.
+- **`second`**: Segundo dígito verificador (14º caractere do CNPJ completo). Lazy, em cache.
+- **`both`**: Ambos os dígitos verificadores concatenados em uma string.
+- **`cnpj`**: O CNPJ completo como string de 14 caracteres (12 da base + 2 dígitos verificadores).
+
+### Formatos de entrada
+
+A classe `CnpjCheckDigits` aceita múltiplos formatos de entrada:
+
+**String:** dígitos e/ou letras crus, ou CNPJ formatado (ex.: `91.415.732/0007-93`, `MG.KGM.J9X/0001-68`). Caracteres não alfanuméricos são removidos; letras minúsculas viram maiúsculas.
+
+**Array de strings:** cada elemento deve ser string; os valores são concatenados e interpretados como uma única string (ex.: `['9','1','4',…]`, `['9141','5732','0007']`, `['MG','KGM','J9X','0001']`). Elementos que não são string não são permitidos.
+
+### Erros e exceções
+
+Este pacote usa a distinção **TypeError vs Exception**: *erros de tipo* indicam uso incorreto da API (ex.: tipo errado); *exceções* indicam dados inválidos ou inelegíveis (ex.: tamanho ou regras de negócio). Você pode capturar classes específicas ou as bases abstratas.
+
+- **CnpjCheckDigitsTypeError** (_abstract_) — base para erros de tipo; estende o `TypeError` do PHP
+- **CnpjCheckDigitsInputTypeError** — entrada não é `string` nem `array` de strings (ou o array contém elemento que não é string)
+- **CnpjCheckDigitsException** (_abstract_) — base para exceções de dados/fluxo; estende `Exception`
+- **CnpjCheckDigitsInputLengthException** — tamanho após sanitização não é 12–14
+- **CnpjCheckDigitsInputInvalidException** — base `00000000`, filial `0000`, ou 12 dígitos numéricos idênticos (padrão de repetição)
+
+```php
+getMessage();
+}
+
+// Tamanho (deve ser 12–14 caracteres alfanuméricos após sanitização)
+try {
+ new CnpjCheckDigits('12345678901');
+} catch (CnpjCheckDigitsInputLengthException $e) {
+ echo $e->getMessage();
+}
+
+// Inválido (ex.: base ou filial zeradas, ou dígitos numéricos repetidos)
+try {
+ new CnpjCheckDigits('000000000001');
+} catch (CnpjCheckDigitsInputInvalidException $e) {
+ echo $e->getMessage();
+}
+
+// Qualquer exceção de dados do pacote
+try {
+ // código arriscado
+} catch (CnpjCheckDigitsException $e) {
+ // tratar
+}
+```
+
+### Outros recursos disponíveis
+
+- **`CNPJ_MIN_LENGTH`**: `12` — constante de classe `CnpjCheckDigits::CNPJ_MIN_LENGTH`, e constante global `Lacus\BrUtils\Cnpj\CNPJ_MIN_LENGTH` quando `cnpj-dv.php` é carregado pelo autoload do Composer.
+- **`CNPJ_MAX_LENGTH`**: `14` — constante de classe `CnpjCheckDigits::CNPJ_MAX_LENGTH`, e constante global `Lacus\BrUtils\Cnpj\CNPJ_MAX_LENGTH` quando `cnpj-dv.php` é carregado pelo autoload do Composer.
+
+## Algoritmo de cálculo
+
+O pacote calcula os dígitos verificadores com as regras oficiais brasileiras de módulo 11 estendidas a caracteres alfanuméricos:
+
+1. **Valor do caractere:** cada caractere contribui com `ord(caractere) − 48` (assim `0`–`9` permanecem 0–9; letras usam o deslocamento ASCII em relação a `0`).
+2. **Pesos:** da **direita para a esquerda**, multiplicar pelos pesos que ciclam **2, 3, 4, 5, 6, 7, 8, 9** e voltam a 2.
+3. **Primeiro dígito verificador (13ª posição):** aplicar os itens 1–2 aos **primeiros 12** caracteres da base; seja `r = soma % 11`. O dígito é `0` se `r < 2`, senão `11 − r`.
+4. **Segundo dígito verificador (14ª posição):** aplicar os itens 1–2 aos 12 primeiros caracteres **mais** o primeiro dígito verificador; mesma fórmula para `r`.
+
+## Contribuição e suporte
+
+Contribuições são bem-vindas! Consulte as [Diretrizes de contribuição](https://github.com/LacusSolutions/br-utils-php/blob/main/CONTRIBUTING.md). Se o projeto for útil para você, considere:
+
+- ⭐ Dar uma estrela no repositório
+- 🤝 Contribuir com código
+- 💡 [Sugerir novas funcionalidades](https://github.com/LacusSolutions/br-utils-php/issues)
+- 🐛 [Reportar bugs](https://github.com/LacusSolutions/br-utils-php/issues)
+
+## Licença
+
+Este projeto está sob a licença MIT — veja o arquivo [LICENSE](https://github.com/LacusSolutions/br-utils-php/blob/main/LICENSE).
+
+## Changelog
+
+Veja o [CHANGELOG](https://github.com/LacusSolutions/br-utils-php/blob/main/packages/cnpj-dv/CHANGELOG.md) para alterações e histórico de versões.
+
+---
+
+Feito com ❤️ por [Lacus Solutions](https://github.com/LacusSolutions)
diff --git a/packages/cnpj-dv/composer.json b/packages/cnpj-dv/composer.json
new file mode 100644
index 0000000..6162415
--- /dev/null
+++ b/packages/cnpj-dv/composer.json
@@ -0,0 +1,61 @@
+{
+ "name": "lacus/cnpj-dv",
+ "type": "library",
+ "description": "Utility to calculate check digits on CNPJ (Brazilian Business Tax ID)",
+ "license": "MIT",
+ "authors": [
+ {
+ "name": "Julio L. Muller",
+ "email": "juliolmuller@outlook.com",
+ "homepage": "https://juliolmuller.github.io"
+ }
+ ],
+ "keywords": [
+ "cnpj",
+ "verificar",
+ "verificador",
+ "verify",
+ "verification",
+ "check",
+ "check-digit",
+ "check-digits",
+ "pt-br",
+ "br"
+ ],
+ "support": {
+ "issues": "https://github.com/LacusSolutions/br-utils-php/issues",
+ "source": "https://github.com/LacusSolutions/br-utils-php"
+ },
+ "homepage": "https://cnpj-utils.vercel.app/",
+ "scripts": {
+ "test": "pest",
+ "test:watch": "pest --watch",
+ "test-coverage": "pest --coverage-html coverage"
+ },
+ "config": {
+ "sort-packages": true,
+ "allow-plugins": {
+ "pestphp/pest-plugin": true
+ }
+ },
+ "require": {
+ "php": "^8.2",
+ "lacus/utils": "^1.0"
+ },
+ "require-dev": {
+ "pestphp/pest": "^3.8"
+ },
+ "autoload": {
+ "psr-4": {
+ "Lacus\\BrUtils\\Cnpj\\": "src/"
+ },
+ "files": [
+ "src/cnpj-dv.php"
+ ]
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Lacus\\BrUtils\\Cnpj\\Tests\\": "tests/"
+ }
+ }
+}
diff --git a/packages/cnpj-dv/phpunit.xml b/packages/cnpj-dv/phpunit.xml
new file mode 100644
index 0000000..8a6dc45
--- /dev/null
+++ b/packages/cnpj-dv/phpunit.xml
@@ -0,0 +1,33 @@
+
+
+
+
+ tests/
+
+
+
+
+ src/
+
+
+ vendor/
+ tests/
+
+
+
+
+
+
+
+
+
diff --git a/packages/cnpj-dv/src/CnpjCheckDigits.php b/packages/cnpj-dv/src/CnpjCheckDigits.php
new file mode 100644
index 0000000..b79f8a2
--- /dev/null
+++ b/packages/cnpj-dv/src/CnpjCheckDigits.php
@@ -0,0 +1,286 @@
+ */
+ private array $cnpjChars;
+ private ?int $cachedFirstDigit = null;
+ private ?int $cachedSecondDigit = null;
+
+ /**
+ * Creates a calculator for the given CNPJ base (12 to 14 characters).
+ *
+ * @param string|list $cnpjInput Alphanumeric CNPJ with or without formatting, or array of strings
+ *
+ * @throws CnpjCheckDigitsInputTypeError When input is not a string or string[].
+ * @throws CnpjCheckDigitsInputLengthException When character count is not between 12 and 14.
+ * @throws CnpjCheckDigitsInputInvalidException When base ID is all zero (`00.000.000`), branch ID is all zero
+ * (`0000`) or all digits are the same (repeated digits, e.g. `77.777.777/7777-...`).
+ */
+ public function __construct(mixed $cnpjInput)
+ {
+ $parsed = $this->parseInput($cnpjInput);
+
+ $this->validateLength($parsed, $cnpjInput);
+ $this->validateBaseId($parsed, $cnpjInput);
+ $this->validateBranchId($parsed, $cnpjInput);
+ $this->validateNonRepeatedDigits($parsed, $cnpjInput);
+
+ $this->cnpjChars = array_slice($parsed, 0, self::CNPJ_MIN_LENGTH);
+ }
+
+ /**
+ * Property-style access to match JS API:
+ * - $cnpjCheckDigits->first
+ * - $cnpjCheckDigits->second
+ * - $cnpjCheckDigits->both
+ * - $cnpjCheckDigits->cnpj
+ */
+ public function __get(string $name): string
+ {
+ return match ($name) {
+ 'first' => $this->getFirst(),
+ 'second' => $this->getSecond(),
+ 'both' => $this->getBoth(),
+ 'cnpj' => $this->getCnpj(),
+ default => throw new InvalidArgumentException("Unknown property: {$name}"),
+ };
+ }
+
+ /**
+ * First check digit (13th character of the full CNPJ).
+ */
+ private function getFirst(): string
+ {
+ if ($this->cachedFirstDigit === null) {
+ $baseCharsSequence = [...$this->cnpjChars];
+ $this->cachedFirstDigit = $this->calculate($baseCharsSequence);
+ }
+
+ return (string) $this->cachedFirstDigit;
+ }
+
+ /**
+ * Second check digit (14th character of the full CNPJ).
+ */
+ private function getSecond(): string
+ {
+ if ($this->cachedSecondDigit === null) {
+ $sequence = [...$this->cnpjChars, $this->getFirst()];
+ $this->cachedSecondDigit = $this->calculate($sequence);
+ }
+
+ return (string) $this->cachedSecondDigit;
+ }
+
+ /**
+ * Both check digits concatenated (13th and 14th characters).
+ */
+ private function getBoth(): string
+ {
+ return $this->getFirst() . $this->getSecond();
+ }
+
+ /**
+ * Full 14-character CNPJ (base 12 characters concatenated with the 2 check digits).
+ */
+ private function getCnpj(): string
+ {
+ return implode('', $this->cnpjChars) . $this->getBoth();
+ }
+
+ /**
+ * Parses a string or an array of strings into an array of alphanumeric characters.
+ *
+ * @param string|list $cnpjInput
+ * @return list
+ *
+ * @throws CnpjCheckDigitsInputTypeError When input is not a string or string[].
+ */
+ private function parseInput(mixed $cnpjInput): array
+ {
+ if (is_string($cnpjInput)) {
+ return $this->parseStringInput($cnpjInput);
+ }
+
+ if (is_array($cnpjInput)) {
+ return $this->parseArrayInput($cnpjInput);
+ }
+
+ throw new CnpjCheckDigitsInputTypeError($cnpjInput, 'string or string[]');
+ }
+
+ /**
+ * Parses a string into an array of alphanumeric characters.
+ *
+ * @return list
+ */
+ private function parseStringInput(string $cnpjString): array
+ {
+ $alphanumericOnly = preg_replace('/[^0-9A-Z]/i', '', $cnpjString) ?? '';
+ $alphanumericUpper = strtoupper($alphanumericOnly);
+ $alphanumericArray = str_split($alphanumericUpper, 1);
+
+ return $alphanumericArray;
+ }
+
+ /**
+ * Parses an array into an array of alphanumeric characters.
+ *
+ * @param list $cnpjArray
+ * @return list
+ *
+ * @throws CnpjCheckDigitsInputTypeError When input is not a string or string[].
+ */
+ private function parseArrayInput(array $cnpjArray): array
+ {
+ if ($cnpjArray === []) {
+ return [];
+ }
+
+ foreach ($cnpjArray as $item) {
+ if (!is_string($item)) {
+ throw new CnpjCheckDigitsInputTypeError($cnpjArray, 'string or string[]');
+ }
+ }
+
+ return $this->parseStringInput(implode('', $cnpjArray));
+ }
+
+ /**
+ * Ensures character count is between 12 and 14.
+ *
+ * @param list $cnpjChars
+ * @param string|list $originalInput
+ */
+ private function validateLength(array $cnpjChars, string|array $originalInput): void
+ {
+ $count = count($cnpjChars);
+
+ if ($count < self::CNPJ_MIN_LENGTH || $count > self::CNPJ_MAX_LENGTH) {
+ throw new CnpjCheckDigitsInputLengthException(
+ $originalInput,
+ implode('', $cnpjChars),
+ self::CNPJ_MIN_LENGTH,
+ self::CNPJ_MAX_LENGTH,
+ );
+ }
+ }
+
+ /**
+ * @param list $cnpjIntArray
+ * @param string|list $originalInput
+ *
+ * @throws CnpjCheckDigitsInputInvalidException When base ID is all zeros.
+ * (`00.000.000`).
+ */
+ private function validateBaseId(array $cnpjIntArray, string|array $originalInput): void
+ {
+ $cnpjBaseIdArray = array_slice($cnpjIntArray, 0, CNPJ_BASE_ID_LAST_INDEX + 1);
+ $cnpjBaseIdString = implode('', $cnpjBaseIdArray);
+
+ if ($cnpjBaseIdString === CNPJ_INVALID_BASE_ID) {
+ throw new CnpjCheckDigitsInputInvalidException(
+ $originalInput,
+ 'Base ID "'.CNPJ_INVALID_BASE_ID.'" is not eligible.',
+ );
+ }
+ }
+
+ /**
+ * @param list $cnpjIntArray
+ * @param string|list $originalInput
+ *
+ * @throws CnpjCheckDigitsInputInvalidException When branch ID is all zeros.
+ * (`0000`).
+ */
+ private function validateBranchId(array $cnpjIntArray, string|array $originalInput): void
+ {
+ $cnpjBranchIdArray = array_slice($cnpjIntArray, CNPJ_BASE_ID_LENGTH, CNPJ_BRANCH_ID_LENGTH);
+ $cnpjBranchIdString = implode('', $cnpjBranchIdArray);
+
+ if ($cnpjBranchIdString === CNPJ_INVALID_BRANCH_ID) {
+ throw new CnpjCheckDigitsInputInvalidException(
+ $originalInput,
+ 'Branch ID "'.CNPJ_INVALID_BRANCH_ID.'" is not eligible.',
+ );
+ }
+ }
+
+ /**
+ * @param list $cnpjIntArray
+ * @param string|list $originalInput
+ *
+ * @throws CnpjCheckDigitsInputInvalidException When all digits are numeric
+ * and the same (repeated digits, e.g. `77.777.777/7777-...`).
+ */
+ private function validateNonRepeatedDigits(array $cnpjIntArray, string|array $originalInput): void
+ {
+ $firstTwelve = array_slice($cnpjIntArray, 0, self::CNPJ_MIN_LENGTH);
+ $unique = array_unique($firstTwelve);
+
+ if (count($unique) === 1 && preg_match('/^\d$/', $firstTwelve[0] ?? '') === 1) {
+ throw new CnpjCheckDigitsInputInvalidException(
+ $originalInput,
+ 'Repeated digits are not considered valid.',
+ );
+ }
+ }
+
+ /**
+ * Computes a single check digit using the standard CNPJ modulo-11 algorithm.
+ *
+ * @param list $cnpjSequence
+ */
+ protected function calculate(array $cnpjSequence): int
+ {
+ $factor = 2;
+ $sumResult = 0;
+
+ for ($i = count($cnpjSequence) - 1; $i >= 0; $i--) {
+ $charValue = ord($cnpjSequence[$i]);
+ $charValue = $charValue - DELTA_FACTOR;
+
+ $sumResult += $charValue * $factor;
+ $factor = $factor === 9 ? 2 : $factor + 1;
+ }
+
+ $remainder = $sumResult % 11;
+
+ return $remainder < 2 ? 0 : 11 - $remainder;
+ }
+}
diff --git a/packages/cnpj-dv/src/Exceptions/CnpjCheckDigitsException.php b/packages/cnpj-dv/src/Exceptions/CnpjCheckDigitsException.php
new file mode 100644
index 0000000..5e92d3b
--- /dev/null
+++ b/packages/cnpj-dv/src/Exceptions/CnpjCheckDigitsException.php
@@ -0,0 +1,19 @@
+ */
+ public string|array $actualInput;
+ public string $reason;
+
+ /** @param string|list $actualInput */
+ public function __construct(string|array $actualInput, string $reason)
+ {
+ $fmtActual = is_string($actualInput)
+ ? "\"{$actualInput}\""
+ : json_encode($actualInput, JSON_THROW_ON_ERROR);
+
+ parent::__construct("CNPJ input {$fmtActual} is invalid. {$reason}");
+ $this->actualInput = $actualInput;
+ $this->reason = $reason;
+ }
+}
diff --git a/packages/cnpj-dv/src/Exceptions/CnpjCheckDigitsInputLengthException.php b/packages/cnpj-dv/src/Exceptions/CnpjCheckDigitsInputLengthException.php
new file mode 100644
index 0000000..b1d4d48
--- /dev/null
+++ b/packages/cnpj-dv/src/Exceptions/CnpjCheckDigitsInputLengthException.php
@@ -0,0 +1,42 @@
+ */
+ public string|array $actualInput;
+ public string $evaluatedInput;
+ public int $minExpectedLength;
+ public int $maxExpectedLength;
+
+ /** @param string|list $actualInput */
+ public function __construct(
+ string|array $actualInput,
+ string $evaluatedInput,
+ int $minExpectedLength,
+ int $maxExpectedLength,
+ ) {
+ $fmtActual = is_string($actualInput)
+ ? "\"{$actualInput}\""
+ : json_encode($actualInput, JSON_THROW_ON_ERROR);
+ $fmtEvaluated = $actualInput === $evaluatedInput
+ ? (string) strlen($evaluatedInput)
+ : strlen($evaluatedInput) . ' in "' . $evaluatedInput . '"';
+
+ parent::__construct("CNPJ input {$fmtActual} does not contain {$minExpectedLength} to {$maxExpectedLength} digits. Got {$fmtEvaluated}.");
+ $this->actualInput = $actualInput;
+ $this->evaluatedInput = $evaluatedInput;
+ $this->minExpectedLength = $minExpectedLength;
+ $this->maxExpectedLength = $maxExpectedLength;
+ }
+}
diff --git a/packages/cnpj-dv/src/Exceptions/CnpjCheckDigitsInputTypeError.php b/packages/cnpj-dv/src/Exceptions/CnpjCheckDigitsInputTypeError.php
new file mode 100644
index 0000000..6085ed7
--- /dev/null
+++ b/packages/cnpj-dv/src/Exceptions/CnpjCheckDigitsInputTypeError.php
@@ -0,0 +1,27 @@
+actualInput = $actualInput;
+ $this->actualType = $actualType;
+ $this->expectedType = $expectedType;
+ }
+}
diff --git a/packages/cnpj-dv/src/cnpj-dv.php b/packages/cnpj-dv/src/cnpj-dv.php
new file mode 100644
index 0000000..31d13ca
--- /dev/null
+++ b/packages/cnpj-dv/src/cnpj-dv.php
@@ -0,0 +1,15 @@
+calculateCallCount++;
+
+ return parent::calculate($cnpjSequence);
+ }
+}
diff --git a/packages/cnpj-dv/tests/Pest.php b/packages/cnpj-dv/tests/Pest.php
new file mode 100644
index 0000000..1f38341
--- /dev/null
+++ b/packages/cnpj-dv/tests/Pest.php
@@ -0,0 +1,13 @@
+in(__DIR__ . DIRECTORY_SEPARATOR . 'Specs');
diff --git a/packages/cnpj-dv/tests/Specs/CnpjCheckDigits.spec.php b/packages/cnpj-dv/tests/Specs/CnpjCheckDigits.spec.php
new file mode 100644
index 0000000..482688c
--- /dev/null
+++ b/packages/cnpj-dv/tests/Specs/CnpjCheckDigits.spec.php
@@ -0,0 +1,511 @@
+ */
+ $testCases = [
+ ['313124260006', '31312426000619'],
+ ['MGKGMJ9X0001', 'MGKGMJ9X000168'],
+ ['1EY6WPPN0001', '1EY6WPPN000164'],
+ ['Y7ELKY990001', 'Y7ELKY99000137'],
+ ['AGPRASLP0001', 'AGPRASLP000123'],
+ ['017205400003', '01720540000374'],
+ ['615208400003', '61520840000331'],
+ ['ABDYZVE90001', 'ABDYZVE9000144'],
+ ['050532360008', '05053236000886'],
+ ['CCLW1PDP0001', 'CCLW1PDP000131'],
+ ['JLNC9SM70001', 'JLNC9SM7000118'],
+ ['51GLNYMV0001', '51GLNYMV000138'],
+ ['003579820002', '00357982000254'],
+ ['573669460004', '57366946000436'],
+ ['412851460002', '41285146000299'],
+ ['159833710006', '15983371000612'],
+ ['R39X6CAD0001', 'R39X6CAD000118'],
+ ['LA031XPE0001', 'LA031XPE000171'],
+ ['8CRCX4G90001', '8CRCX4G9000145'],
+ ['002439100008', '00243910000871'],
+ ['570635620003', '57063562000363'],
+ ['210890360007', '21089036000759'],
+ ['483494070001', '48349407000155'],
+ ['871056390003', '87105639000381'],
+ ['ZP64G0G50001', 'ZP64G0G5000169'],
+ ['RTCR3YKJ0001', 'RTCR3YKJ000139'],
+ ['914157320007', '91415732000793'],
+ ['167805610002', '16780561000271'],
+ ['4SGW7L2V0001', '4SGW7L2V000192'],
+ ['51CGZ6CE0001', '51CGZ6CE000166'],
+ ['4TD25XEB0001', '4TD25XEB000186'],
+ ['C892RYMB0001', 'C892RYMB000166'],
+ ['006645070002', '00664507000220'],
+ ['711081470005', '71108147000571'],
+ ['410302000007', '41030200000760'],
+ ['863940890002', '86394089000214'],
+ ['CCBHVLD70001', 'CCBHVLD7000109'],
+ ['Y8E3T0H20001', 'Y8E3T0H2000127'],
+ ['015206300003', '01520630000311'],
+ ['4LHTLHRR0001', '4LHTLHRR000129'],
+ ['669041680003', '66904168000300'],
+ ['470076350005', '47007635000508'],
+ ['DSX3851R0001', 'DSX3851R000123'],
+ ['517503930003', '51750393000353'],
+ ['456189710004', '45618971000480'],
+ ['SVAERM5X0001', 'SVAERM5X000180'],
+ ['479281750001', '47928175000127'],
+ ['TVHW9KYC0001', 'TVHW9KYC000168'],
+ ['982882590009', '98288259000931'],
+ ['648275500008', '64827550000838'],
+ ['023543810003', '02354381000302'],
+ ['HPC6L9ZB0001', 'HPC6L9ZB000101'],
+ ['822313180002', '82231318000229'],
+ ['W7SJP7J10001', 'W7SJP7J1000104'],
+ ['784153420007', '78415342000755'],
+ ['451264770004', '45126477000407'],
+ ['HHVRZ7860001', 'HHVRZ786000190'],
+ ['4BB2CZY00001', '4BB2CZY0000107'],
+ ['YYWVGRDP0001', 'YYWVGRDP000103'],
+ ['005792660004', '00579266000483'],
+ ['2V802ATH0001', '2V802ATH000108'],
+ ['HVWA2TC40001', 'HVWA2TC4000139'],
+ ['J4LR5KNM0001', 'J4LR5KNM000119'],
+ ['KL46HEJ50001', 'KL46HEJ5000106'],
+ ['SZS0X62H0001', 'SZS0X62H000177'],
+ ['JM6VWMAZ0001', 'JM6VWMAZ000126'],
+ ['885435950009', '88543595000920'],
+ ['1DYMEV6W0001', '1DYMEV6W000188'],
+ ['758805710006', '75880571000671'],
+ ['NK78LS4Z0001', 'NK78LS4Z000127'],
+ ['238857260004', '23885726000405'],
+ ['723362430001', '72336243000106'],
+ ['JG3TE2X30001', 'JG3TE2X3000167'],
+ ['782152520001', '78215252000125'],
+ ['283366280009', '28336628000939'],
+ ['E6SN8JC40001', 'E6SN8JC4000149'],
+ ['79YJNKHZ0001', '79YJNKHZ000110'],
+ ['47GZ4GL10001', '47GZ4GL1000127'],
+ ['069523030004', '06952303000433'],
+ ['474080600006', '47408060000616'],
+ ['040693560006', '04069356000647'],
+ ['THTV6BM20001', 'THTV6BM2000157'],
+ ['TPY675ZN0001', 'TPY675ZN000119'],
+ ['KS4E7SAA0001', 'KS4E7SAA000176'],
+ ['NMPEHEVB0001', 'NMPEHEVB000129'],
+ ['1M917XTB0001', '1M917XTB000176'],
+ ['J9M0ZD510001', 'J9M0ZD51000123'],
+ ['P0G334BY0001', 'P0G334BY000136'],
+ ['076394320005', '07639432000510'],
+ ['992040290001', '99204029000152'],
+ ['2D56RWZP0001', '2D56RWZP000195'],
+ ['M68N7W6L0001', 'M68N7W6L000175'],
+ ['LH9B5RXK0001', 'LH9B5RXK000171'],
+ ['495517490003', '49551749000388'],
+ ['307168390003', '30716839000353'],
+ ['Y0EBSBLT0001', 'Y0EBSBLT000105'],
+ ['C9DASM460001', 'C9DASM46000190'],
+ ['ZZ0172HG0001', 'ZZ0172HG000130'],
+ ['6DYLY5060001', '6DYLY506000113'],
+ ['JE5TKSJ80001', 'JE5TKSJ8000109'],
+ ['TRPYT31P0001', 'TRPYT31P000124'],
+ ['144863760009', '14486376000910'],
+ ['KZEWGKT60001', 'KZEWGKT6000198'],
+ ['S28361BX0001', 'S28361BX000165'],
+ ['6VK1VBLW0001', '6VK1VBLW000154'],
+ ['KJT4XC490001', 'KJT4XC49000165'],
+ ['H8SS5ZTT0001', 'H8SS5ZTT000104'],
+ ['5PYHBL870001', '5PYHBL87000149'],
+ ['ZAB6JG9E0001', 'ZAB6JG9E000148'],
+ ['354946770003', '35494677000370'],
+ ['J0EHJEXT0001', 'J0EHJEXT000130'],
+ ['539MLKGS0001', '539MLKGS000154'],
+ ['319476190003', '31947619000301'],
+ ['ZWW4XY8X0001', 'ZWW4XY8X000183'],
+ ['D83TW2JG0001', 'D83TW2JG000100'],
+ ['KPJR04DT0001', 'KPJR04DT000143'],
+ ['301272110005', '30127211000584'],
+ ['G4T4BTDR0001', 'G4T4BTDR000120'],
+ ['509053950004', '50905395000492'],
+ ['W95P9DKV0001', 'W95P9DKV000194'],
+ ];
+
+ $repeatedDigitInputs = [
+ '111111111111',
+ '222222222222',
+ '333333333333',
+ '444444444444',
+ '555555555555',
+ '666666666666',
+ '777777777777',
+ '888888888888',
+ '999999999999',
+ ['111111111111'],
+ ['222222222222'],
+ ['333333333333'],
+ ['444444444444'],
+ ['555555555555'],
+ ['666666666666'],
+ ['777777777777'],
+ ['888888888888'],
+ ['999999999999'],
+ ['11', '111', '111', '1111'],
+ ['22', '222', '222', '2222'],
+ ['33', '333', '333', '3333'],
+ ['44', '444', '444', '4444'],
+ ['55', '555', '555', '5555'],
+ ['66', '666', '666', '6666'],
+ ['77', '777', '777', '7777'],
+ ['88', '888', '888', '8888'],
+ ['99', '999', '999', '9999'],
+ ['1', '1', '1', '1', '1', '1', '1', '1', '1', '1', '1', '1'],
+ ['2', '2', '2', '2', '2', '2', '2', '2', '2', '2', '2', '2'],
+ ['3', '3', '3', '3', '3', '3', '3', '3', '3', '3', '3', '3'],
+ ['4', '4', '4', '4', '4', '4', '4', '4', '4', '4', '4', '4'],
+ ['5', '5', '5', '5', '5', '5', '5', '5', '5', '5', '5', '5'],
+ ['6', '6', '6', '6', '6', '6', '6', '6', '6', '6', '6', '6'],
+ ['7', '7', '7', '7', '7', '7', '7', '7', '7', '7', '7', '7'],
+ ['8', '8', '8', '8', '8', '8', '8', '8', '8', '8', '8', '8'],
+ ['9', '9', '9', '9', '9', '9', '9', '9', '9', '9', '9', '9'],
+ ];
+
+ $repeatedLetterInputs = [
+ 'AAAAAAAAAAAA',
+ 'BBBBBBBBBBBB',
+ 'CCCCCCCCCCCC',
+ 'JJJJJJJJJJJJ',
+ 'KKKKKKKKKKKK',
+ 'LLLLLLLLLLLL',
+ 'XXXXXXXXXXXX',
+ 'YYYYYYYYYYYY',
+ 'ZZZZZZZZZZZZ',
+ ['AAAAAAAAAAAA'],
+ ['BBBBBBBBBBBB'],
+ ['CCCCCCCCCCCC'],
+ ['JJJJJJJJJJJJ'],
+ ['KKKKKKKKKKKK'],
+ ['LLLLLLLLLLLL'],
+ ['XXXXXXXXXXXX'],
+ ['YYYYYYYYYYYY'],
+ ['ZZZZZZZZZZZZ'],
+ ['A', 'A', 'A', 'A', 'A', 'A', 'A', 'A', 'A', 'A', 'A', 'A'],
+ ['B', 'B', 'B', 'B', 'B', 'B', 'B', 'B', 'B', 'B', 'B', 'B'],
+ ['C', 'C', 'C', 'C', 'C', 'C', 'C', 'C', 'C', 'C', 'C', 'C'],
+ ['J', 'J', 'J', 'J', 'J', 'J', 'J', 'J', 'J', 'J', 'J', 'J'],
+ ['K', 'K', 'K', 'K', 'K', 'K', 'K', 'K', 'K', 'K', 'K', 'K'],
+ ['L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L'],
+ ['X', 'X', 'X', 'X', 'X', 'X', 'X', 'X', 'X', 'X', 'X', 'X'],
+ ['Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y'],
+ ['Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z'],
+ ];
+
+ describe('constructor', function () use ($repeatedDigitInputs, $repeatedLetterInputs) {
+ describe('when given invalid input type', function () {
+ it('throws CnpjCheckDigitsInputTypeError for integer input', function () {
+ /** @var mixed $invalid */
+ $invalid = 12345678901;
+
+ expect(fn () => new CnpjCheckDigits($invalid))->toThrow(CnpjCheckDigitsInputTypeError::class);
+ });
+
+ it('throws CnpjCheckDigitsInputTypeError for null input', function () {
+ /** @var mixed $invalid */
+ $invalid = null;
+
+ expect(fn () => new CnpjCheckDigits($invalid))->toThrow(CnpjCheckDigitsInputTypeError::class);
+ });
+
+ it('throws CnpjCheckDigitsInputTypeError for object input', function () {
+ /** @var mixed $invalid */
+ $invalid = (object) ['cnpj' => '12345678901'];
+
+ expect(fn () => new CnpjCheckDigits($invalid))->toThrow(CnpjCheckDigitsInputTypeError::class);
+ });
+
+ it('throws CnpjCheckDigitsInputTypeError for array of numbers', function () {
+ /** @var mixed $invalid */
+ $invalid = [1, 2, 3, 4, 5, 6, 7, 8, 9];
+
+ expect(fn () => new CnpjCheckDigits($invalid))->toThrow(CnpjCheckDigitsInputTypeError::class);
+ });
+
+ it('throws CnpjCheckDigitsInputTypeError for mixed array types', function () {
+ /** @var mixed $invalid */
+ $invalid = [1, '2', 3, '4', 5];
+
+ expect(fn () => new CnpjCheckDigits($invalid))->toThrow(CnpjCheckDigitsInputTypeError::class);
+ });
+ });
+
+ describe('when given invalid input length', function () {
+ it('throws CnpjCheckDigitsInputLengthException for empty string', function () {
+ /** @var mixed $invalid */
+ $invalid = '';
+
+ expect(fn () => new CnpjCheckDigits($invalid))->toThrow(CnpjCheckDigitsInputLengthException::class);
+ });
+
+ it('throws CnpjCheckDigitsInputLengthException for empty array', function () {
+ expect(fn () => new CnpjCheckDigits([]))->toThrow(CnpjCheckDigitsInputLengthException::class);
+ });
+
+ it('throws CnpjCheckDigitsInputLengthException for string with 11 alphanumeric characters', function () {
+ /** @var mixed $invalid */
+ $invalid = '12345678910';
+
+ expect(fn () => new CnpjCheckDigits($invalid))->toThrow(CnpjCheckDigitsInputLengthException::class);
+ });
+
+ it('throws CnpjCheckDigitsInputLengthException for string with 15 alphanumeric characters', function () {
+ /** @var mixed $invalid */
+ $invalid = '123456789101112';
+
+ expect(fn () => new CnpjCheckDigits($invalid))->toThrow(CnpjCheckDigitsInputLengthException::class);
+ });
+
+ it('throws CnpjCheckDigitsInputLengthException for string array with 11 characters', function () {
+ /** @var mixed $invalid */
+ $invalid = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10'];
+
+ expect(fn () => new CnpjCheckDigits($invalid))->toThrow(CnpjCheckDigitsInputLengthException::class);
+ });
+
+ it('throws CnpjCheckDigitsInputLengthException for string array with 15 characters', function () {
+ /** @var mixed $invalid */
+ $invalid = [
+ '0', '0', '1', '1', '1', '2', '2', '2', '0', '0', '0', '4', '5', '6', '7',
+ ];
+
+ expect(fn () => new CnpjCheckDigits($invalid))->toThrow(CnpjCheckDigitsInputLengthException::class);
+ });
+ });
+
+ describe('when given invalid CNPJ base ID', function () {
+ /** @var list> */
+ $invalidBaseIdInputs = [
+ '000000000001',
+ '00.000.000/0001',
+ ['00', '000', '000', '0001'],
+ ['0', '0', '0', '0', '0', '0', '0', '0', '0', '0', '0', '1'],
+ ];
+
+ it('throws CnpjCheckDigitsInputInvalidException for base ID', function (string|array $input) {
+ expect(fn () => new CnpjCheckDigits($input))->toThrow(CnpjCheckDigitsInputInvalidException::class);
+
+ try {
+ new CnpjCheckDigits($input);
+ } catch (CnpjCheckDigitsInputInvalidException $e) {
+ expect($e->getMessage())->toMatch('/base id/i');
+ }
+ })->with(
+ array_map(
+ static fn (string|array $item): array => [$item],
+ $invalidBaseIdInputs,
+ )
+ );
+ });
+
+ describe('when given invalid CNPJ branch ID', function () {
+ /** @var list> */
+ $invalidBranchIdInputs = [
+ '123456780000',
+ '12345678/0000',
+ ['12', '345', '678', '0000'],
+ ['1', '2', '3', '4', '5', '6', '7', '8', '0', '0', '0', '0'],
+ ];
+
+ it('throws CnpjCheckDigitsInputInvalidException for branch ID', function (string|array $input) {
+ expect(fn () => new CnpjCheckDigits($input))->toThrow(CnpjCheckDigitsInputInvalidException::class);
+
+ try {
+ new CnpjCheckDigits($input);
+ } catch (CnpjCheckDigitsInputInvalidException $e) {
+ expect($e->getMessage())->toMatch('/branch id/i');
+ }
+ })->with(
+ array_map(
+ static fn (string|array $item): array => [$item],
+ $invalidBranchIdInputs,
+ )
+ );
+ });
+
+ describe('when given repeated numeric characters', function () use ($repeatedDigitInputs) {
+ it('throws CnpjCheckDigitsInputInvalidException for repeated-digit input', function (string|array $input) {
+ expect(fn () => new CnpjCheckDigits($input))->toThrow(CnpjCheckDigitsInputInvalidException::class);
+
+ try {
+ new CnpjCheckDigits($input);
+ } catch (CnpjCheckDigitsInputInvalidException $e) {
+ expect($e->getMessage())->toMatch('/repeated digits/i');
+ }
+ })->with(array_map(static fn (string|array $item): array => [$item], $repeatedDigitInputs));
+ });
+
+ describe('when given repeated non-numeric characters', function () use ($repeatedLetterInputs) {
+ it('does not throw error for repeated-letter input', function (string|array $input) {
+ $cnpjCheckDigits = new CnpjCheckDigits($input);
+ $stringifiedInput = is_array($input) ? implode('', $input) : $input;
+
+ expect($cnpjCheckDigits)->toBeInstanceOf(CnpjCheckDigits::class)
+ ->and(strlen($cnpjCheckDigits->cnpj))->toBe(14)
+ ->and(str_starts_with($cnpjCheckDigits->cnpj, $stringifiedInput))->toBeTrue();
+ })->with(array_map(static fn (string|array $item): array => [$item], $repeatedLetterInputs));
+ });
+ });
+
+ describe('first digit', function () use ($testCases) {
+ $firstDigitTestCases = [];
+
+ foreach ($testCases as [$input, $expectedFull]) {
+ $firstDigitTestCases[] = [$input, substr($expectedFull, -2, 1)];
+ }
+
+ describe('when input is a string', function () use ($firstDigitTestCases) {
+ it('returns `$expected` as first digit for `$input`', function (string $input, string $expected) {
+ $cnpjCheckDigits = new CnpjCheckDigits($input);
+
+ expect($cnpjCheckDigits->first)->toBe($expected);
+ })->with($firstDigitTestCases);
+ });
+
+ describe('when input is an array of strings', function () use ($firstDigitTestCases) {
+ it('returns `$expected` as first digit for `$input`', function (string $input, string $expected) {
+ $cnpjCheckDigits = new CnpjCheckDigits(str_split($input, 1));
+
+ expect($cnpjCheckDigits->first)->toBe($expected);
+ })->with($firstDigitTestCases);
+ });
+
+ describe('when accessing digits multiple times', function () {
+ it('returns cached values on subsequent calls', function () {
+ $cnpjCheckDigits = new CnpjCheckDigitsWithCalculateSpy('914157320007');
+
+ $cnpjCheckDigits->first;
+ $cnpjCheckDigits->first;
+ $cnpjCheckDigits->first;
+
+ expect($cnpjCheckDigits->calculateCallCount)->toBe(1);
+ });
+ });
+ });
+
+ describe('second digit', function () use ($testCases) {
+ $secondDigitTestCases = [];
+
+ foreach ($testCases as [$input, $expectedFull]) {
+ $secondDigitTestCases[] = [$input, substr($expectedFull, -1)];
+ }
+
+ describe('when input is a string', function () use ($secondDigitTestCases) {
+ it('returns `$expected` as second digit for `$input`', function (string $input, string $expected) {
+ $cnpjCheckDigits = new CnpjCheckDigits($input);
+
+ expect($cnpjCheckDigits->second)->toBe($expected);
+ })->with($secondDigitTestCases);
+ });
+
+ describe('when input is an array of strings', function () use ($secondDigitTestCases) {
+ it('returns `$expected` as second digit for `$input`', function (string $input, string $expected) {
+ $cnpjCheckDigits = new CnpjCheckDigits(str_split($input, 1));
+
+ expect($cnpjCheckDigits->second)->toBe($expected);
+ })->with($secondDigitTestCases);
+ });
+
+ describe('when accessing digits multiple times', function () {
+ it('returns cached values on subsequent calls', function () {
+ $cnpjCheckDigits = new CnpjCheckDigitsWithCalculateSpy('914157320007');
+
+ $cnpjCheckDigits->second;
+ $cnpjCheckDigits->second;
+ $cnpjCheckDigits->second;
+
+ expect($cnpjCheckDigits->calculateCallCount)->toBe(2);
+ });
+ });
+ });
+
+ describe('both digits', function () use ($testCases) {
+ $bothDigitsTestCases = [];
+
+ foreach ($testCases as [$input, $expectedFull]) {
+ $bothDigitsTestCases[] = [$input, substr($expectedFull, -2)];
+ }
+
+ describe('when input is a string', function () use ($bothDigitsTestCases) {
+ it('returns `$expected` as check digits for `$input`', function (string $input, string $expected) {
+ $cnpjCheckDigits = new CnpjCheckDigits($input);
+
+ expect($cnpjCheckDigits->both)->toBe($expected);
+ })->with($bothDigitsTestCases);
+ });
+
+ describe('when input is an array of strings', function () use ($bothDigitsTestCases) {
+ it('returns `$expected` as check digits for `$input`', function (string $input, string $expected) {
+ $cnpjCheckDigits = new CnpjCheckDigits(str_split($input, 1));
+
+ expect($cnpjCheckDigits->both)->toBe($expected);
+ })->with($bothDigitsTestCases);
+ });
+ });
+
+ describe('actual CNPJ string', function () use ($testCases) {
+ describe('when input is a string', function () {
+ it('returns the respective 14-character string for CNPJ', function () {
+ $cnpjCheckDigits = new CnpjCheckDigits('914157320007');
+
+ expect($cnpjCheckDigits->cnpj)->toBe('91415732000793');
+ });
+ });
+
+ describe('when input is an array of grouped characters', function () {
+ it('returns the respective 14-character string for CNPJ', function () {
+ $cnpjCheckDigits = new CnpjCheckDigits(['9141', '5732', '0007']);
+
+ expect($cnpjCheckDigits->cnpj)->toBe('91415732000793');
+ });
+ });
+
+ describe('when input is an array of individual characters', function () {
+ it('returns the respective 14-character string for CNPJ', function () {
+ $cnpjCheckDigits = new CnpjCheckDigits(['9', '1', '4', '1', '5', '7', '3', '2', '0', '0', '0', '7']);
+
+ expect($cnpjCheckDigits->cnpj)->toBe('91415732000793');
+ });
+ });
+
+ describe('when validating all test cases', function () use ($testCases) {
+ it('returns `$expected` for `$input`', function (string $input, string $expected) {
+ $cnpjCheckDigits = new CnpjCheckDigits($input);
+
+ expect($cnpjCheckDigits->cnpj)->toBe($expected);
+ })->with($testCases);
+ });
+ });
+
+ describe('edge cases', function () {
+ describe('when input is a formatted CNPJ string', function () {
+ it('correctly parses and calculates check digits', function () {
+ $cnpjCheckDigits = new CnpjCheckDigits('91.415.732/0007');
+
+ expect($cnpjCheckDigits->cnpj)->toBe('91415732000793');
+ });
+ });
+
+ describe('when input already contains check digits', function () {
+ it('ignores provided check digits and calculates ones correctly', function () {
+ $cnpjCheckDigits = new CnpjCheckDigits('91415732000700');
+
+ expect($cnpjCheckDigits->first)->toBe('9');
+ expect($cnpjCheckDigits->second)->toBe('3');
+ expect($cnpjCheckDigits->cnpj)->toBe('91415732000793');
+ });
+ });
+ });
+});
diff --git a/packages/cnpj-dv/tests/Specs/Exceptions.spec.php b/packages/cnpj-dv/tests/Specs/Exceptions.spec.php
new file mode 100644
index 0000000..301932f
--- /dev/null
+++ b/packages/cnpj-dv/tests/Specs/Exceptions.spec.php
@@ -0,0 +1,257 @@
+toBeInstanceOf(TypeError::class);
+ });
+
+ it('is an instance of CnpjCheckDigitsTypeError', function () {
+ $error = new TestCnpjCheckDigitsTypeError();
+
+ expect($error)->toBeInstanceOf(CnpjCheckDigitsTypeError::class);
+ });
+
+ it('has the correct class name', function () {
+ $error = new TestCnpjCheckDigitsTypeError();
+
+ expect($error::class)->toBe(TestCnpjCheckDigitsTypeError::class);
+ });
+
+ it('sets the `actualInput` property', function () {
+ $error = new TestCnpjCheckDigitsTypeError();
+
+ expect($error->actualInput)->toBe(123);
+ });
+
+ it('sets the `actualType` property', function () {
+ $error = new TestCnpjCheckDigitsTypeError();
+
+ expect($error->actualType)->toBe('number');
+ });
+
+ it('sets the `expectedType` property', function () {
+ $error = new TestCnpjCheckDigitsTypeError();
+
+ expect($error->expectedType)->toBe('string');
+ });
+
+ it('has a `message` property', function () {
+ $error = new TestCnpjCheckDigitsTypeError();
+
+ expect($error->getMessage())->toBe('some error');
+ });
+ });
+});
+
+describe('CnpjCheckDigitsInputTypeError', function () {
+ describe('when instantiated', function () {
+ it('is an instance of TypeError', function () {
+ $error = new CnpjCheckDigitsInputTypeError(123, 'string');
+
+ expect($error)->toBeInstanceOf(TypeError::class);
+ });
+
+ it('is an instance of CnpjCheckDigitsTypeError', function () {
+ $error = new CnpjCheckDigitsInputTypeError(123, 'string');
+
+ expect($error)->toBeInstanceOf(CnpjCheckDigitsTypeError::class);
+ });
+
+ it('has the correct class name', function () {
+ $error = new CnpjCheckDigitsInputTypeError(123, 'string');
+
+ expect($error::class)->toBe(CnpjCheckDigitsInputTypeError::class);
+ });
+
+ it('sets the `actualInput` property', function () {
+ $input = 123;
+ $error = new CnpjCheckDigitsInputTypeError($input, 'string');
+
+ expect($error->actualInput)->toBe($input);
+ });
+
+ it('sets the `actualType` property', function () {
+ $error = new CnpjCheckDigitsInputTypeError(123, 'string');
+
+ expect($error->actualType)->toBe('integer number');
+ });
+
+ it('sets the `expectedType` property', function () {
+ $error = new CnpjCheckDigitsInputTypeError(123, 'string or string[]');
+
+ expect($error->expectedType)->toBe('string or string[]');
+ });
+
+ it('generates a message describing the error', function () {
+ $actualInput = 123;
+ $actualType = 'integer number';
+ $expectedType = 'string[]';
+ $actualMessage = "CNPJ input must be of type {$expectedType}. Got {$actualType}.";
+
+ $error = new CnpjCheckDigitsInputTypeError($actualInput, $expectedType);
+
+ expect($error->getMessage())->toBe($actualMessage);
+ });
+ });
+});
+
+describe('CnpjCheckDigitsException', function () {
+ describe('when instantiated through a subclass', function () {
+ it('is an instance of Exception', function () {
+ $exception = new TestCnpjCheckDigitsException('some error');
+
+ expect($exception)->toBeInstanceOf(\Exception::class);
+ });
+
+ it('is an instance of CnpjCheckDigitsException', function () {
+ $exception = new TestCnpjCheckDigitsException('some error');
+
+ expect($exception)->toBeInstanceOf(CnpjCheckDigitsException::class);
+ });
+
+ it('has the correct class name', function () {
+ $exception = new TestCnpjCheckDigitsException('some error');
+
+ expect($exception::class)->toBe(TestCnpjCheckDigitsException::class);
+ });
+
+ it('has a `message` property', function () {
+ $exception = new TestCnpjCheckDigitsException('some error');
+
+ expect($exception->getMessage())->toBe('some error');
+ });
+ });
+});
+
+describe('CnpjCheckDigitsInputLengthException', function () {
+ describe('when instantiated', function () {
+ it('is an instance of Exception', function () {
+ $exception = new CnpjCheckDigitsInputLengthException('1.2.3.4.5', '12345', 12, 14);
+
+ expect($exception)->toBeInstanceOf(\Exception::class);
+ });
+
+ it('is an instance of CnpjCheckDigitsException', function () {
+ $exception = new CnpjCheckDigitsInputLengthException('1.2.3.4.5', '12345', 12, 14);
+
+ expect($exception)->toBeInstanceOf(CnpjCheckDigitsException::class);
+ });
+
+ it('has the correct class name', function () {
+ $exception = new CnpjCheckDigitsInputLengthException('1.2.3.4.5', '12345', 12, 14);
+
+ expect($exception::class)->toBe(CnpjCheckDigitsInputLengthException::class);
+ });
+
+ it('sets the `actualInput` property', function () {
+ $exception = new CnpjCheckDigitsInputLengthException('1.2.3.4.5', '12345', 12, 14);
+
+ expect($exception->actualInput)->toBe('1.2.3.4.5');
+ });
+
+ it('sets the `evaluatedInput` property', function () {
+ $exception = new CnpjCheckDigitsInputLengthException('1.2.3.4.5', '12345', 12, 14);
+
+ expect($exception->evaluatedInput)->toBe('12345');
+ });
+
+ it('sets the `minExpectedLength` property', function () {
+ $exception = new CnpjCheckDigitsInputLengthException('1.2.3.4.5', '12345', 12, 14);
+
+ expect($exception->minExpectedLength)->toBe(12);
+ });
+
+ it('sets the `maxExpectedLength` property', function () {
+ $exception = new CnpjCheckDigitsInputLengthException('1.2.3.4.5', '12345', 12, 14);
+
+ expect($exception->maxExpectedLength)->toBe(14);
+ });
+
+ it('generates a message describing the exception', function () {
+ $actualInput = '1.2.3.4.5';
+ $evaluatedInput = '12345';
+ $minExpectedLength = 12;
+ $maxExpectedLength = 14;
+ $actualMessage = 'CNPJ input "'.$actualInput.'" does not contain '.$minExpectedLength.' to '.$maxExpectedLength.' digits. Got '.strlen($evaluatedInput).' in "'.$evaluatedInput.'".';
+
+ $exception = new CnpjCheckDigitsInputLengthException(
+ $actualInput,
+ $evaluatedInput,
+ $minExpectedLength,
+ $maxExpectedLength,
+ );
+
+ expect($exception->getMessage())->toBe($actualMessage);
+ });
+ });
+});
+
+describe('CnpjCheckDigitsInputInvalidException', function () {
+ describe('when instantiated', function () {
+ it('is an instance of Exception', function () {
+ $exception = new CnpjCheckDigitsInputInvalidException('1.2.3.4.5', 'repeated digits');
+
+ expect($exception)->toBeInstanceOf(\Exception::class);
+ });
+
+ it('is an instance of CnpjCheckDigitsException', function () {
+ $exception = new CnpjCheckDigitsInputInvalidException('1.2.3.4.5', 'repeated digits');
+
+ expect($exception)->toBeInstanceOf(CnpjCheckDigitsException::class);
+ });
+
+ it('has the correct class name', function () {
+ $exception = new CnpjCheckDigitsInputInvalidException('1.2.3.4.5', 'repeated digits');
+
+ expect($exception::class)->toBe(CnpjCheckDigitsInputInvalidException::class);
+ });
+
+ it('sets the `actualInput` property', function () {
+ $exception = new CnpjCheckDigitsInputInvalidException('1.2.3.4.5', 'repeated digits');
+
+ expect($exception->actualInput)->toBe('1.2.3.4.5');
+ });
+
+ it('sets the `reason` property', function () {
+ $exception = new CnpjCheckDigitsInputInvalidException('1.2.3.4.5', 'repeated digits');
+
+ expect($exception->reason)->toBe('repeated digits');
+ });
+
+ it('generates a message describing the exception', function () {
+ $actualInput = '1.2.3.4.5';
+ $reason = 'repeated digits';
+ $actualMessage = 'CNPJ input "'.$actualInput.'" is invalid. '.$reason;
+
+ $exception = new CnpjCheckDigitsInputInvalidException($actualInput, $reason);
+
+ expect($exception->getMessage())->toBe($actualMessage);
+ });
+ });
+});
diff --git a/packages/cnpj-dv/tests/Specs/Package.spec.php b/packages/cnpj-dv/tests/Specs/Package.spec.php
new file mode 100644
index 0000000..51b80b1
--- /dev/null
+++ b/packages/cnpj-dv/tests/Specs/Package.spec.php
@@ -0,0 +1,71 @@
+toBe(12)
+ ->and(CNPJ_MIN_LENGTH)->toBe(12);
+ });
+
+ it('exposes CNPJ_MAX_LENGTH on the class and as a global constant', function () {
+ expect(CnpjCheckDigits::CNPJ_MAX_LENGTH)->toBe(14)
+ ->and(CNPJ_MAX_LENGTH)->toBe(14);
+ });
+ });
+
+ describe('when inspecting public types', function () {
+ it('exposes CnpjCheckDigits as an instantiable class', function () {
+ $instance = new CnpjCheckDigits('914157320007');
+
+ expect($instance)->toBeInstanceOf(CnpjCheckDigits::class)
+ ->and($instance->first)->toBe('9')
+ ->and($instance->second)->toBe('3')
+ ->and($instance->cnpj)->toBe('91415732000793');
+ });
+
+ it('exposes CnpjCheckDigitsTypeError as an abstract type', function () {
+ expect((new ReflectionClass(CnpjCheckDigitsTypeError::class))->isAbstract())->toBeTrue();
+ });
+
+ it('exposes CnpjCheckDigitsInputTypeError as instantiable', function () {
+ $instance = new CnpjCheckDigitsInputTypeError(123, 'string');
+
+ expect($instance->actualInput)->toBe(123)
+ ->and($instance->getMessage())->toBe('CNPJ input must be of type string. Got integer number.');
+ });
+
+ it('exposes CnpjCheckDigitsException as an abstract type', function () {
+ expect((new ReflectionClass(CnpjCheckDigitsException::class))->isAbstract())->toBeTrue();
+ });
+
+ it('exposes CnpjCheckDigitsInputInvalidException as instantiable', function () {
+ $instance = new CnpjCheckDigitsInputInvalidException('123', 'some reason');
+
+ expect($instance->actualInput)->toBe('123')
+ ->and($instance->reason)->toBe('some reason')
+ ->and($instance->getMessage())->toBe('CNPJ input "123" is invalid. some reason');
+ });
+
+ it('exposes CnpjCheckDigitsInputLengthException as instantiable', function () {
+ $instance = new CnpjCheckDigitsInputLengthException('x', '1', 12, 14);
+
+ expect($instance->minExpectedLength)->toBe(12)
+ ->and($instance->maxExpectedLength)->toBe(14);
+ });
+ });
+});
diff --git a/packages/cpf-dv/src/CpfCheckDigits.php b/packages/cpf-dv/src/CpfCheckDigits.php
index 45aafae..633e813 100644
--- a/packages/cpf-dv/src/CpfCheckDigits.php
+++ b/packages/cpf-dv/src/CpfCheckDigits.php
@@ -21,10 +21,10 @@
class CpfCheckDigits
{
/** Minimum number of digits required for the CPF check digits calculation. */
- public const CPF_MIN_LENGTH = 9;
+ public const CPF_MIN_LENGTH = CPF_MIN_LENGTH;
/** Maximum number of digits accepted as input for the CPF check digits calculation. */
- public const CPF_MAX_LENGTH = 11;
+ public const CPF_MAX_LENGTH = CPF_MAX_LENGTH;
/** @var list */
private array $cpfDigits;
@@ -43,10 +43,6 @@ class CpfCheckDigits
*/
public function __construct(mixed $cpfInput)
{
- if (!is_string($cpfInput) && !is_array($cpfInput)) {
- throw new CpfCheckDigitsInputTypeError($cpfInput, 'string or string[]');
- }
-
$parsed = $this->parseInput($cpfInput);
$this->validateLength($parsed, $cpfInput);
@@ -116,45 +112,48 @@ private function getCpf(): string
}
/**
- * Parses input (string or array of strings) into an array of digit integers.
+ * Parses a string or an array of strings into an array of integers.
*
* @param string|list $cpfInput
* @return list
+ *
+ * @throws CpfCheckDigitsInputTypeError When input is not a string or string[].
*/
- private function parseInput(string|array $cpfInput): array
+ private function parseInput(mixed $cpfInput): array
{
if (is_string($cpfInput)) {
- return $this->handleStringInput($cpfInput);
+ return $this->parseStringInput($cpfInput);
+ }
+
+ if (is_array($cpfInput)) {
+ return $this->parseArrayInput($cpfInput);
}
- return $this->handleArrayInput($cpfInput);
+ throw new CpfCheckDigitsInputTypeError($cpfInput, 'string or string[]');
}
/**
- * Parses a string into an array of numbers.
+ * Parses a string into an array of integers.
*
* @return list
*/
- private function handleStringInput(string $cpfString): array
+ private function parseStringInput(string $cpfString): array
{
- $digitsOnly = preg_replace('/\D/', '', $cpfString);
-
- if ($digitsOnly === null) {
- $digitsOnly = '';
- }
-
+ $digitsOnly = preg_replace('/\D/', '', $cpfString) ?? '';
$chars = str_split($digitsOnly, 1);
return array_map('intval', $chars);
}
/**
- * Normalizes array input to a string and delegates to number parsing.
+ * Parses an array into an array of integers.
*
* @param list $cpfArray
* @return list
+ *
+ * @throws CpfCheckDigitsInputTypeError When input is not a string or string[].
*/
- private function handleArrayInput(array $cpfArray): array
+ private function parseArrayInput(array $cpfArray): array
{
if ($cpfArray === []) {
return [];
@@ -166,7 +165,7 @@ private function handleArrayInput(array $cpfArray): array
}
}
- return $this->handleStringInput(implode('', $cpfArray));
+ return $this->parseStringInput(implode('', $cpfArray));
}
/**
diff --git a/packages/cpf-dv/src/cpf-dv.php b/packages/cpf-dv/src/cpf-dv.php
index e063fc0..4e554b4 100644
--- a/packages/cpf-dv/src/cpf-dv.php
+++ b/packages/cpf-dv/src/cpf-dv.php
@@ -6,12 +6,10 @@
/**
* Minimum number of digits required for the CPF check digits calculation.
- * Same as CpfCheckDigits::CPF_MIN_LENGTH.
*/
const CPF_MIN_LENGTH = 9;
/**
* Maximum number of digits accepted as input for the CPF check digits calculation.
- * Same as CpfCheckDigits::CPF_MAX_LENGTH.
*/
const CPF_MAX_LENGTH = 11;
diff --git a/packages/cpf-dv/tests/Specs/Package.spec.php b/packages/cpf-dv/tests/Specs/Package.spec.php
index 59d34fd..bf8ad32 100644
--- a/packages/cpf-dv/tests/Specs/Package.spec.php
+++ b/packages/cpf-dv/tests/Specs/Package.spec.php
@@ -13,6 +13,7 @@
use Lacus\BrUtils\Cpf\Exceptions\CpfCheckDigitsInputLengthException;
use Lacus\BrUtils\Cpf\Exceptions\CpfCheckDigitsInputTypeError;
use Lacus\BrUtils\Cpf\Exceptions\CpfCheckDigitsTypeError;
+use ReflectionClass;
describe('the cpf-dv package surface', function () {
describe('when inspecting constants', function () {
@@ -38,7 +39,7 @@
});
it('exposes CpfCheckDigitsTypeError as an abstract type', function () {
- expect((new \ReflectionClass(CpfCheckDigitsTypeError::class))->isAbstract())->toBeTrue();
+ expect((new ReflectionClass(CpfCheckDigitsTypeError::class))->isAbstract())->toBeTrue();
});
it('exposes CpfCheckDigitsInputTypeError as instantiable', function () {
@@ -49,7 +50,7 @@
});
it('exposes CpfCheckDigitsException as an abstract type', function () {
- expect((new \ReflectionClass(CpfCheckDigitsException::class))->isAbstract())->toBeTrue();
+ expect((new ReflectionClass(CpfCheckDigitsException::class))->isAbstract())->toBeTrue();
});
it('exposes CpfCheckDigitsInputInvalidException as instantiable', function () {