diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b8e1217..90748da 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -19,7 +19,6 @@ jobs:
max-parallel: 2
matrix:
php-version:
- - 8.1
- 8.2
- 8.3
- 8.4
@@ -47,7 +46,6 @@ jobs:
max-parallel: 1
matrix:
php-version:
- - 8.1
- 8.2
- 8.3
- 8.4
@@ -58,6 +56,7 @@ jobs:
- cnpj-gen
- cnpj-val
- cnpj-utils
+ - cpf-dv
- cpf-fmt
- cpf-gen
- cpf-val
diff --git a/packages/cpf-dv/CHANGELOG.md b/packages/cpf-dv/CHANGELOG.md
new file mode 100644
index 0000000..c90b935
--- /dev/null
+++ b/packages/cpf-dv/CHANGELOG.md
@@ -0,0 +1,15 @@
+# lacus/cpf-dv
+
+## 1.0.0
+
+### 🚀 Stable Version Released!
+
+Utility class to calculate check digits on CPF (Brazilian Individual's Taxpayer ID). 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/cpf-dv/LICENSE b/packages/cpf-dv/LICENSE
new file mode 100644
index 0000000..1c8640a
--- /dev/null
+++ b/packages/cpf-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/cpf-dv/README.md b/packages/cpf-dv/README.md
new file mode 100644
index 0000000..737a3be
--- /dev/null
+++ b/packages/cpf-dv/README.md
@@ -0,0 +1,150 @@
+
+
+[](https://packagist.org/packages/lacus/cpf-dv)
+[](https://packagist.org/packages/lacus/cpf-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)
+
+> 🌎 [Acessar documentação em português](https://github.com/LacusSolutions/br-utils-php/blob/main/packages/cpf-dv/README.pt.md)
+
+A PHP utility to calculate check digits on CPF (Brazilian Individual's Taxpayer ID).
+
+## PHP Support
+
+|  |  |  |  |
+| --- | --- | --- | --- |
+| Passing ✔ | Passing ✔ | Passing ✔ | Passing ✔ |
+
+## Features
+
+- ✅ **Flexible input**: Accepts `string` or `array` of strings
+- ✅ **Format agnostic**: Automatically strips non-numeric characters from string input
+- ✅ **Auto-expansion**: Multi-character strings in arrays are expanded to individual 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`, `cpf` (via magic `__get`)
+- ✅ **Minimal dependencies**: Only [`lacus/utils`](https://packagist.org/packages/lacus/utils)
+- ✅ **Error handling**: Specific types for type, length, and invalid CPF scenarios (`TypeError` vs `Exception` semantics)
+
+## Installation
+
+```bash
+# using Composer
+$ composer require lacus/cpf-dv
+```
+
+## Quick Start
+
+```php
+first; // '1'
+$checkDigits->second; // '0'
+$checkDigits->both; // '10'
+$checkDigits->cpf; // '05449651910'
+```
+
+## Usage
+
+The main resource of this package is the class `CpfCheckDigits`. Through an instance, you access CPF check-digit information:
+
+- **`__construct`**: `new CpfCheckDigits(string|array $cpfInput)` — 9–11 digits (formatting stripped from strings).
+- **`first`**: First check digit (10th digit of the CPF). Lazy, cached.
+- **`second`**: Second check digit (11th digit of the CPF). Lazy, cached.
+- **`both`**: Both check digits concatenated as a string.
+- **`cpf`**: The complete CPF as a string of 11 digits (9 base digits + 2 check digits).
+
+### Input formats
+
+The `CpfCheckDigits` class accepts multiple input formats:
+
+**String input:** plain digits or formatted CPF (e.g. `054.496.519-10`). Non-numeric characters are automatically stripped. Use 9 digits (base only) or 11 digits (only the first 9 are used).
+
+**Array of strings:** single-character strings or multi-character strings (expanded to individual digits), e.g. `['0','5','4','4','9','6','5','1','9']`, `['054496519']`, `['054','496','519']`.
+
+### 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 CPF). You can catch specific classes or use the abstract bases.
+
+- **CpfCheckDigitsTypeError** (_abstract_) — base for type errors; extends PHP’s `TypeError`
+- **CpfCheckDigitsInputTypeError** — input is not `string` or `string[]`
+- **CpfCheckDigitsException** (_abstract_) — base for data/flow exceptions; extends `Exception`
+- **CpfCheckDigitsInputLengthException** — sanitized length is not 9–11
+- **CpfCheckDigitsInputInvalidException** — input ineligible (e.g. repeated digits like `111111111`)
+
+```php
+getMessage();
+}
+
+// Length (must be 9–11 digits after sanitization)
+try {
+ new CpfCheckDigits('12345678');
+} catch (CpfCheckDigitsInputLengthException $e) {
+ echo $e->getMessage();
+}
+
+// Invalid (e.g. repeated digits)
+try {
+ new CpfCheckDigits(['999', '999', '999']);
+} catch (CpfCheckDigitsInputInvalidException $e) {
+ echo $e->getMessage();
+}
+
+// Any data exception from the package
+try {
+ // risky code
+} catch (CpfCheckDigitsException $e) {
+ // handle
+}
+```
+
+### Other available resources
+
+- **`CPF_MIN_LENGTH`**: `9` — class constant `CpfCheckDigits::CPF_MIN_LENGTH`, and global `Lacus\BrUtils\Cpf\CPF_MIN_LENGTH` when the autoloaded `cpf-dv.php` file is loaded.
+- **`CPF_MAX_LENGTH`**: `11` — class constant `CpfCheckDigits::CPF_MAX_LENGTH`, and global `Lacus\BrUtils\Cpf\CPF_MAX_LENGTH` when `cpf-dv.php` is loaded.
+
+## Calculation algorithm
+
+The package calculates CPF check digits using the official Brazilian algorithm:
+
+1. **First check digit (10th position):** digits 1–9 of the CPF base; weights 10, 9, 8, 7, 6, 5, 4, 3, 2 (from left to right); `remainder = 11 - (sum(digit × weight) % 11)`; result is `0` if remainder > 9, otherwise `remainder`.
+2. **Second check digit (11th position):** digits 1–9 + first check digit; weights 11, 10, 9, 8, 7, 6, 5, 4, 3, 2 (from left to right); same formula.
+
+## 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/cpf-dv/CHANGELOG.md) for a list of changes and version history.
+
+---
+
+Made with ❤️ by [Lacus Solutions](https://github.com/LacusSolutions)
diff --git a/packages/cpf-dv/README.pt.md b/packages/cpf-dv/README.pt.md
new file mode 100644
index 0000000..5d13399
--- /dev/null
+++ b/packages/cpf-dv/README.pt.md
@@ -0,0 +1,137 @@
+
+
+> 🌎 [Access documentation in English](https://github.com/LacusSolutions/br-utils-php/blob/main/packages/cpf-dv/README.md)
+
+Utilitário em PHP para calcular os dígitos verificadores de CPF (Cadastro de Pessoa Física).
+
+## Recursos
+
+- ✅ **Entrada flexível**: Aceita `string` ou `array` de strings
+- ✅ **Agnóstico ao formato**: Remove automaticamente caracteres não numéricos da entrada em string
+- ✅ **Auto-expansão**: Strings com vários caracteres em arrays são expandidas para dígitos individuais
+- ✅ **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`, `cpf` (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 CPF inválido (semântica `TypeError` vs `Exception`)
+
+## Instalação
+
+```bash
+# usando Composer
+$ composer require lacus/cpf-dv
+```
+
+## Início rápido
+
+```php
+first; // '1'
+$checkDigits->second; // '0'
+$checkDigits->both; // '10'
+$checkDigits->cpf; // '05449651910'
+```
+
+## Utilização
+
+O principal recurso deste pacote é a classe `CpfCheckDigits`. Por meio da instância, você acessa as informações dos dígitos verificadores do CPF:
+
+- **`__construct`**: `new CpfCheckDigits(string|array $cpfInput)` — 9–11 dígitos (formatação removida de strings).
+- **`first`**: Primeiro dígito verificador (10º dígito do CPF). Lazy, em cache.
+- **`second`**: Segundo dígito verificador (11º dígito do CPF). Lazy, em cache.
+- **`both`**: Ambos os dígitos verificadores concatenados em uma string.
+- **`cpf`**: O CPF completo como string de 11 dígitos (9 da base + 2 dígitos verificadores).
+
+### Formatos de entrada
+
+A classe `CpfCheckDigits` aceita múltiplos formatos de entrada:
+
+**String:** dígitos crus ou CPF formatado (ex.: `054.496.519-10`). Caracteres não numéricos são removidos automaticamente. Use 9 dígitos (apenas base) ou 11 dígitos (apenas os 9 primeiros são usados).
+
+**Array de strings:** strings de um caractere ou de vários (expandidas para dígitos individuais), ex.: `['0','5','4','4','9','6','5','1','9']`, `['054496519']`, `['054','496','519']`.
+
+### 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 ineligíveis (ex.: CPF inválido). Você pode capturar classes específicas ou as bases abstratas.
+
+- **CpfCheckDigitsTypeError** (_abstract_) — base para erros de tipo; estende o `TypeError` do PHP
+- **CpfCheckDigitsInputTypeError** — entrada não é `string` nem `string[]`
+- **CpfCheckDigitsException** (_abstract_) — base para exceções de dados/fluxo; estende `Exception`
+- **CpfCheckDigitsInputLengthException** — tamanho após sanitização não é 9–11
+- **CpfCheckDigitsInputInvalidException** — entrada ineligível (ex.: dígitos repetidos como `111111111`)
+
+```php
+getMessage();
+}
+
+// Tamanho (deve ser 9–11 dígitos após sanitização)
+try {
+ new CpfCheckDigits('12345678');
+} catch (CpfCheckDigitsInputLengthException $e) {
+ echo $e->getMessage();
+}
+
+// Inválido (ex.: dígitos repetidos)
+try {
+ new CpfCheckDigits(['999', '999', '999']);
+} catch (CpfCheckDigitsInputInvalidException $e) {
+ echo $e->getMessage();
+}
+
+// Qualquer exceção de dados do pacote
+try {
+ // código arriscado
+} catch (CpfCheckDigitsException $e) {
+ // tratar
+}
+```
+
+### Outros recursos disponíveis
+
+- **`CPF_MIN_LENGTH`**: `9` — constante de classe `CpfCheckDigits::CPF_MIN_LENGTH`, e constante global `Lacus\BrUtils\Cpf\CPF_MIN_LENGTH` quando `cpf-dv.php` é carregado pelo autoload do Composer.
+- **`CPF_MAX_LENGTH`**: `11` — constante de classe `CpfCheckDigits::CPF_MAX_LENGTH`, e constante global `Lacus\BrUtils\Cpf\CPF_MAX_LENGTH` quando `cpf-dv.php` é carregado pelo autoload do Composer.
+
+## Algoritmo de cálculo
+
+O pacote calcula os dígitos verificadores do CPF usando o algoritmo oficial brasileiro:
+
+1. **Primeiro dígito (10ª posição):** dígitos 1–9 da base do CPF; pesos 10, 9, 8, 7, 6, 5, 4, 3, 2 (da esquerda para a direita); `resto = 11 - (soma(dígito × peso) % 11)`; resultado é `0` se resto > 9, caso contrário `resto`.
+2. **Segundo dígito (11ª posição):** dígitos 1–9 + primeiro dígito verificador; pesos 11, 10, 9, 8, 7, 6, 5, 4, 3, 2 (da esquerda para a direita); mesma fórmula.
+
+## 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/cpf-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/cpf-dv/composer.json b/packages/cpf-dv/composer.json
new file mode 100644
index 0000000..6d31497
--- /dev/null
+++ b/packages/cpf-dv/composer.json
@@ -0,0 +1,61 @@
+{
+ "name": "lacus/cpf-dv",
+ "type": "library",
+ "description": "Utility to calculate check digits on CPF (Brazilian Individual's Taxpayer ID)",
+ "license": "MIT",
+ "authors": [
+ {
+ "name": "Julio L. Muller",
+ "email": "juliolmuller@outlook.com",
+ "homepage": "https://juliolmuller.github.io"
+ }
+ ],
+ "keywords": [
+ "cpf",
+ "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://cpf-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\\Cpf\\": "src/"
+ },
+ "files": [
+ "src/cpf-dv.php"
+ ]
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Lacus\\BrUtils\\Cpf\\Tests\\": "tests/"
+ }
+ }
+}
diff --git a/packages/cpf-dv/phpunit.xml b/packages/cpf-dv/phpunit.xml
new file mode 100644
index 0000000..ee1839f
--- /dev/null
+++ b/packages/cpf-dv/phpunit.xml
@@ -0,0 +1,33 @@
+
+
+
+
+ tests/
+
+
+
+
+ src/
+
+
+ vendor/
+ tests/
+
+
+
+
+
+
+
+
+
diff --git a/packages/cpf-dv/src/CpfCheckDigits.php b/packages/cpf-dv/src/CpfCheckDigits.php
new file mode 100644
index 0000000..45aafae
--- /dev/null
+++ b/packages/cpf-dv/src/CpfCheckDigits.php
@@ -0,0 +1,232 @@
+ */
+ private array $cpfDigits;
+ private ?int $cachedFirstDigit = null;
+ private ?int $cachedSecondDigit = null;
+
+ /**
+ * Creates a calculator for the given CPF base (9 to 11 digits).
+ *
+ * @param string|list $cpfInput digits with or without formatting, or array of strings
+ *
+ * @throws CpfCheckDigitsInputTypeError When input is not a string or string[].
+ * @throws CpfCheckDigitsInputLengthException When digit count is not between 9 and 11.
+ * @throws CpfCheckDigitsInputInvalidException When all digits are the same
+ * (repeated digits, e.g. 777.777.777-...).
+ */
+ 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);
+ $this->validateNonRepeatedDigits($parsed, $cpfInput);
+
+ $this->cpfDigits = array_slice($parsed, 0, self::CPF_MIN_LENGTH);
+ }
+
+ /**
+ * Property-style access to match JS API:
+ * - $cpfCheckDigits->first
+ * - $cpfCheckDigits->second
+ * - $cpfCheckDigits->both
+ * - $cpfCheckDigits->cpf
+ */
+ public function __get(string $name): string
+ {
+ return match ($name) {
+ 'first' => $this->getFirst(),
+ 'second' => $this->getSecond(),
+ 'both' => $this->getBoth(),
+ 'cpf' => $this->getCpf(),
+ default => throw new InvalidArgumentException("Unknown property: {$name}"),
+ };
+ }
+
+ /**
+ * First check digit (10th digit of the full CPF).
+ */
+ private function getFirst(): string
+ {
+ if ($this->cachedFirstDigit === null) {
+ $sequence = [...$this->cpfDigits];
+ $this->cachedFirstDigit = $this->calculate($sequence);
+ }
+
+ return (string) $this->cachedFirstDigit;
+ }
+
+ /**
+ * Second check digit (11th digit of the full CPF).
+ */
+ private function getSecond(): string
+ {
+ if ($this->cachedSecondDigit === null) {
+ $sequence = [...$this->cpfDigits, (int) $this->getFirst()];
+ $this->cachedSecondDigit = $this->calculate($sequence);
+ }
+
+ return (string) $this->cachedSecondDigit;
+ }
+
+ /**
+ * Both check digits concatenated (10th and 11th digits).
+ */
+ private function getBoth(): string
+ {
+ return $this->getFirst() . $this->getSecond();
+ }
+
+ /**
+ * Full 11-digit CPF (base 9 digits concatenated with the 2 check digits).
+ */
+ private function getCpf(): string
+ {
+ return implode('', $this->cpfDigits) . $this->getBoth();
+ }
+
+ /**
+ * Parses input (string or array of strings) into an array of digit integers.
+ *
+ * @param string|list $cpfInput
+ * @return list
+ */
+ private function parseInput(string|array $cpfInput): array
+ {
+ if (is_string($cpfInput)) {
+ return $this->handleStringInput($cpfInput);
+ }
+
+ return $this->handleArrayInput($cpfInput);
+ }
+
+ /**
+ * Parses a string into an array of numbers.
+ *
+ * @return list
+ */
+ private function handleStringInput(string $cpfString): array
+ {
+ $digitsOnly = preg_replace('/\D/', '', $cpfString);
+
+ if ($digitsOnly === null) {
+ $digitsOnly = '';
+ }
+
+ $chars = str_split($digitsOnly, 1);
+
+ return array_map('intval', $chars);
+ }
+
+ /**
+ * Normalizes array input to a string and delegates to number parsing.
+ *
+ * @param list $cpfArray
+ * @return list
+ */
+ private function handleArrayInput(array $cpfArray): array
+ {
+ if ($cpfArray === []) {
+ return [];
+ }
+
+ foreach ($cpfArray as $item) {
+ if (!is_string($item)) {
+ throw new CpfCheckDigitsInputTypeError($cpfArray, 'string or string[]');
+ }
+ }
+
+ return $this->handleStringInput(implode('', $cpfArray));
+ }
+
+ /**
+ * Ensures digit count is between CPF_MIN_LENGTH and CPF_MAX_LENGTH.
+ *
+ * @param list $digits
+ * @param string|list $originalInput
+ */
+ private function validateLength(array $digits, string|array $originalInput): void
+ {
+ $count = count($digits);
+
+ if ($count < self::CPF_MIN_LENGTH || $count > self::CPF_MAX_LENGTH) {
+ $evaluated = implode('', $digits);
+
+ throw new CpfCheckDigitsInputLengthException(
+ $originalInput,
+ $evaluated,
+ self::CPF_MIN_LENGTH,
+ self::CPF_MAX_LENGTH,
+ );
+ }
+ }
+
+ /**
+ * Rejects inputs where all first 9 digits are the same.
+ *
+ * @param list $digits
+ * @param string|list $originalInput
+ */
+ private function validateNonRepeatedDigits(array $digits, string|array $originalInput): void
+ {
+ $firstNine = array_slice($digits, 0, self::CPF_MIN_LENGTH);
+ $unique = array_unique($firstNine);
+
+ if (count($unique) === 1) {
+ throw new CpfCheckDigitsInputInvalidException(
+ $originalInput,
+ 'Repeated digits are not considered valid.',
+ );
+ }
+ }
+
+ /**
+ * Computes a single check digit using the standard CPF modulo-11 algorithm.
+ *
+ * @param list $cpfSequence
+ */
+ protected function calculate(array $cpfSequence): int
+ {
+ $factor = count($cpfSequence) + 1;
+ $sumResult = 0;
+
+ foreach ($cpfSequence as $num) {
+ $sumResult += $num * $factor;
+ $factor -= 1;
+ }
+
+ $remainder = 11 - ($sumResult % 11);
+
+ return $remainder > 9 ? 0 : $remainder;
+ }
+}
diff --git a/packages/cpf-dv/src/Exceptions/CpfCheckDigitsException.php b/packages/cpf-dv/src/Exceptions/CpfCheckDigitsException.php
new file mode 100644
index 0000000..dd0b621
--- /dev/null
+++ b/packages/cpf-dv/src/Exceptions/CpfCheckDigitsException.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("CPF input {$fmtActual} is invalid. {$reason}");
+ $this->actualInput = $actualInput;
+ $this->reason = $reason;
+ }
+}
diff --git a/packages/cpf-dv/src/Exceptions/CpfCheckDigitsInputLengthException.php b/packages/cpf-dv/src/Exceptions/CpfCheckDigitsInputLengthException.php
new file mode 100644
index 0000000..d133a2b
--- /dev/null
+++ b/packages/cpf-dv/src/Exceptions/CpfCheckDigitsInputLengthException.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("CPF 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/cpf-dv/src/Exceptions/CpfCheckDigitsInputTypeError.php b/packages/cpf-dv/src/Exceptions/CpfCheckDigitsInputTypeError.php
new file mode 100644
index 0000000..ee2180a
--- /dev/null
+++ b/packages/cpf-dv/src/Exceptions/CpfCheckDigitsInputTypeError.php
@@ -0,0 +1,27 @@
+actualInput = $actualInput;
+ $this->actualType = $actualType;
+ $this->expectedType = $expectedType;
+ }
+}
diff --git a/packages/cpf-dv/src/cpf-dv.php b/packages/cpf-dv/src/cpf-dv.php
new file mode 100644
index 0000000..e063fc0
--- /dev/null
+++ b/packages/cpf-dv/src/cpf-dv.php
@@ -0,0 +1,17 @@
+calculateCallCount++;
+
+ return parent::calculate($cpfSequence);
+ }
+}
diff --git a/packages/cpf-dv/tests/Pest.php b/packages/cpf-dv/tests/Pest.php
new file mode 100644
index 0000000..1f38341
--- /dev/null
+++ b/packages/cpf-dv/tests/Pest.php
@@ -0,0 +1,13 @@
+in(__DIR__ . DIRECTORY_SEPARATOR . 'Specs');
diff --git a/packages/cpf-dv/tests/Specs/CpfCheckDigits.spec.php b/packages/cpf-dv/tests/Specs/CpfCheckDigits.spec.php
new file mode 100644
index 0000000..8ee95d5
--- /dev/null
+++ b/packages/cpf-dv/tests/Specs/CpfCheckDigits.spec.php
@@ -0,0 +1,343 @@
+ */
+ $testCases = [
+ ['054496519', '05449651910'],
+ ['965376562', '96537656206'],
+ ['339670768', '33967076806'],
+ ['623855638', '62385563827'],
+ ['582286009', '58228600950'],
+ ['935218534', '93521853403'],
+ ['132115335', '13211533508'],
+ ['492602225', '49260222575'],
+ ['341428925', '34142892533'],
+ ['727598627', '72759862720'],
+ ['478880583', '47888058396'],
+ ['336636977', '33663697797'],
+ ['859249430', '85924943038'],
+ ['306829569', '30682956961'],
+ ['443539643', '44353964321'],
+ ['439709507', '43970950783'],
+ ['557601402', '55760140221'],
+ ['951159579', '95115957922'],
+ ['671669104', '67166910496'],
+ ['627571100', '62757110004'],
+ ['515930555', '51593055560'],
+ ['303472731', '30347273130'],
+ ['728843365', '72884336508'],
+ ['523667424', '52366742479'],
+ ['513362164', '51336216476'],
+ ['427546407', '42754640797'],
+ ['880696512', '88069651237'],
+ ['571430852', '57143085227'],
+ ['561416205', '56141620540'],
+ ['769627950', '76962795050'],
+ ['416603400', '41660340063'],
+ ['853803696', '85380369634'],
+ ['484667676', '48466767657'],
+ ['058588388', '05858838820'],
+ ['862778820', '86277882007'],
+ ['047126827', '04712682752'],
+ ['881801816', '88180181677'],
+ ['932053118', '93205311884'],
+ ['029783613', '02978361379'],
+ ['950189877', '95018987766'],
+ ['842528992', '84252899206'],
+ ['216901618', '21690161809'],
+ ['110478730', '11047873001'],
+ ['032967591', '03296759158'],
+ ['700386565', '70038656531'],
+ ['929036812', '92903681287'],
+ ['750529972', '75052997272'],
+ ['481063058', '48106305872'],
+ ['307721932', '30772193282'],
+ ['994799423', '99479942364'],
+ ];
+
+ $repeatedDigitInputs = [
+ '111111111',
+ '222222222',
+ '333333333',
+ '444444444',
+ '555555555',
+ '666666666',
+ '777777777',
+ '888888888',
+ '999999999',
+ '000000000',
+ ['111', '111', '111'],
+ ['222', '222', '222'],
+ ['333', '333', '333'],
+ ['444', '444', '444'],
+ ['555', '555', '555'],
+ ['666', '666', '666'],
+ ['777', '777', '777'],
+ ['888', '888', '888'],
+ ['999', '999', '999'],
+ ['000', '000', '000'],
+ ['1', '1', '1', '1', '1', '1', '1', '1', '1'],
+ ['2', '2', '2', '2', '2', '2', '2', '2', '2'],
+ ['3', '3', '3', '3', '3', '3', '3', '3', '3'],
+ ['4', '4', '4', '4', '4', '4', '4', '4', '4'],
+ ['5', '5', '5', '5', '5', '5', '5', '5', '5'],
+ ['6', '6', '6', '6', '6', '6', '6', '6', '6'],
+ ['7', '7', '7', '7', '7', '7', '7', '7', '7'],
+ ['8', '8', '8', '8', '8', '8', '8', '8', '8'],
+ ['9', '9', '9', '9', '9', '9', '9', '9', '9'],
+ ['0', '0', '0', '0', '0', '0', '0', '0', '0'],
+ ];
+
+ describe('constructor', function () use ($repeatedDigitInputs) {
+ describe('when given invalid input type', function () {
+ it('throws CpfCheckDigitsInputTypeError for integer input', function () {
+ /** @var mixed $invalid */
+ $invalid = 12345678901;
+
+ expect(fn () => new CpfCheckDigits($invalid))->toThrow(CpfCheckDigitsInputTypeError::class);
+ });
+
+ it('throws CpfCheckDigitsInputTypeError for null input', function () {
+ /** @var mixed $invalid */
+ $invalid = null;
+
+ expect(fn () => new CpfCheckDigits($invalid))->toThrow(CpfCheckDigitsInputTypeError::class);
+ });
+
+ it('throws CpfCheckDigitsInputTypeError for object input', function () {
+ /** @var mixed $invalid */
+ $invalid = (object) ['cpf' => '12345678901'];
+
+ expect(fn () => new CpfCheckDigits($invalid))->toThrow(CpfCheckDigitsInputTypeError::class);
+ });
+
+ it('throws CpfCheckDigitsInputTypeError for array of numbers', function () {
+ /** @var mixed $invalid */
+ $invalid = [1, 2, 3, 4, 5, 6, 7, 8, 9];
+
+ expect(fn () => new CpfCheckDigits($invalid))->toThrow(CpfCheckDigitsInputTypeError::class);
+ });
+
+ it('throws CpfCheckDigitsInputTypeError for mixed array types', function () {
+ /** @var mixed $invalid */
+ $invalid = [1, '2', 3, '4', 5];
+
+ expect(fn () => new CpfCheckDigits($invalid))->toThrow(CpfCheckDigitsInputTypeError::class);
+ });
+ });
+
+ describe('when given invalid input length', function () {
+ it('throws CpfCheckDigitsInputLengthException for empty string', function () {
+ /** @var mixed $invalid */
+ $invalid = '';
+
+ expect(fn () => new CpfCheckDigits($invalid))->toThrow(CpfCheckDigitsInputLengthException::class);
+ });
+
+ it('throws CpfCheckDigitsInputLengthException for empty array', function () {
+ expect(fn () => new CpfCheckDigits([]))->toThrow(CpfCheckDigitsInputLengthException::class);
+ });
+
+ it('throws CpfCheckDigitsInputLengthException for non-numeric string', function () {
+ /** @var mixed $invalid */
+ $invalid = 'abcdefghij';
+
+ expect(fn () => new CpfCheckDigits($invalid))->toThrow(CpfCheckDigitsInputLengthException::class);
+ });
+
+ it('throws CpfCheckDigitsInputLengthException for string with 8 digits', function () {
+ /** @var mixed $invalid */
+ $invalid = '12345678';
+
+ expect(fn () => new CpfCheckDigits($invalid))->toThrow(CpfCheckDigitsInputLengthException::class);
+ });
+
+ it('throws CpfCheckDigitsInputLengthException for string with 12 digits', function () {
+ /** @var mixed $invalid */
+ $invalid = '123456789100';
+
+ expect(fn () => new CpfCheckDigits($invalid))->toThrow(CpfCheckDigitsInputLengthException::class);
+ });
+
+ it('throws CpfCheckDigitsInputLengthException for string array with 8 digits', function () {
+ /** @var mixed $invalid */
+ $invalid = ['1', '2', '3', '4', '5', '6', '7', '8'];
+
+ expect(fn () => new CpfCheckDigits($invalid))->toThrow(CpfCheckDigitsInputLengthException::class);
+ });
+
+ it('throws CpfCheckDigitsInputLengthException for string array with 12 digits', function () {
+ /** @var mixed $invalid */
+ $invalid = ['0', '5', '4', '4', '9', '6', '5', '1', '9', '1', '0', '0'];
+
+ expect(fn () => new CpfCheckDigits($invalid))->toThrow(CpfCheckDigitsInputLengthException::class);
+ });
+ });
+
+ describe('when given repeated digits', function () use ($repeatedDigitInputs) {
+ it('throws CpfCheckDigitsInputInvalidException for repeated-digit input', function (string|array $input) {
+ expect(fn () => new CpfCheckDigits($input))->toThrow(CpfCheckDigitsInputInvalidException::class);
+ })->with(array_map(static fn (string|array $item): array => [$item], $repeatedDigitInputs));
+ });
+ });
+
+ 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) {
+ $cpfCheckDigits = new CpfCheckDigits($input);
+
+ expect($cpfCheckDigits->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) {
+ $cpfCheckDigits = new CpfCheckDigits(str_split($input, 1));
+
+ expect($cpfCheckDigits->first)->toBe($expected);
+ })->with($firstDigitTestCases);
+ });
+
+ describe('when accessing digits multiple times', function () {
+ it('returns cached values on subsequent calls', function () {
+ $cpfCheckDigits = new CpfCheckDigitsWithCalculateSpy('123456789');
+
+ $cpfCheckDigits->first;
+ $cpfCheckDigits->first;
+ $cpfCheckDigits->first;
+
+ expect($cpfCheckDigits->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) {
+ $cpfCheckDigits = new CpfCheckDigits($input);
+
+ expect($cpfCheckDigits->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) {
+ $cpfCheckDigits = new CpfCheckDigits(str_split($input, 1));
+
+ expect($cpfCheckDigits->second)->toBe($expected);
+ })->with($secondDigitTestCases);
+ });
+
+ describe('when accessing digits multiple times', function () {
+ it('returns cached values on subsequent calls', function () {
+ $cpfCheckDigits = new CpfCheckDigitsWithCalculateSpy('123456789');
+
+ $cpfCheckDigits->second;
+ $cpfCheckDigits->second;
+ $cpfCheckDigits->second;
+
+ expect($cpfCheckDigits->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) {
+ $cpfCheckDigits = new CpfCheckDigits($input);
+
+ expect($cpfCheckDigits->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) {
+ $cpfCheckDigits = new CpfCheckDigits(str_split($input, 1));
+
+ expect($cpfCheckDigits->both)->toBe($expected);
+ })->with($bothDigitsTestCases);
+ });
+ });
+
+ describe('actual CPF string', function () use ($testCases) {
+ describe('when input is a string', function () {
+ it('returns the respective 11-character string for CPF', function () {
+ $cpfCheckDigits = new CpfCheckDigits('123456789');
+
+ expect($cpfCheckDigits->cpf)->toBe('12345678909');
+ });
+ });
+
+ describe('when input is an array of grouped digits string', function () {
+ it('returns the respective 11-character string for CPF', function () {
+ $cpfCheckDigits = new CpfCheckDigits(['123', '456', '789']);
+
+ expect($cpfCheckDigits->cpf)->toBe('12345678909');
+ });
+ });
+
+ describe('when input is an array of individual digits string', function () {
+ it('returns the respective 11-character string for CPF', function () {
+ $cpfCheckDigits = new CpfCheckDigits(['1', '2', '3', '4', '5', '6', '7', '8', '9']);
+
+ expect($cpfCheckDigits->cpf)->toBe('12345678909');
+ });
+ });
+
+ describe('when validating all test cases', function () use ($testCases) {
+ it('returns `$expected` for `$input`', function (string $input, string $expected) {
+ $cpfCheckDigits = new CpfCheckDigits($input);
+
+ expect($cpfCheckDigits->cpf)->toBe($expected);
+ })->with($testCases);
+ });
+ });
+
+ describe('edge cases', function () {
+ describe('when input is a formatted CPF string', function () {
+ it('correctly parses and calculates check digits', function () {
+ $cpfCheckDigits = new CpfCheckDigits('123.456.789');
+
+ expect($cpfCheckDigits->cpf)->toBe('12345678909');
+ });
+ });
+
+ describe('when input already contains check digits', function () {
+ it('ignores provided check digits and calculates ones correctly', function () {
+ $cpfCheckDigits = new CpfCheckDigits('12345678910');
+
+ expect($cpfCheckDigits->first)->toBe('0');
+ expect($cpfCheckDigits->second)->toBe('9');
+ expect($cpfCheckDigits->cpf)->toBe('12345678909');
+ });
+ });
+ });
+});
diff --git a/packages/cpf-dv/tests/Specs/Exceptions.spec.php b/packages/cpf-dv/tests/Specs/Exceptions.spec.php
new file mode 100644
index 0000000..7d97a6b
--- /dev/null
+++ b/packages/cpf-dv/tests/Specs/Exceptions.spec.php
@@ -0,0 +1,257 @@
+toBeInstanceOf(TypeError::class);
+ });
+
+ it('is an instance of CpfCheckDigitsTypeError', function () {
+ $error = new TestCpfCheckDigitsTypeError();
+
+ expect($error)->toBeInstanceOf(CpfCheckDigitsTypeError::class);
+ });
+
+ it('has the correct class name', function () {
+ $error = new TestCpfCheckDigitsTypeError();
+
+ expect($error::class)->toBe(TestCpfCheckDigitsTypeError::class);
+ });
+
+ it('sets the `actualInput` property', function () {
+ $error = new TestCpfCheckDigitsTypeError();
+
+ expect($error->actualInput)->toBe(123);
+ });
+
+ it('sets the `actualType` property', function () {
+ $error = new TestCpfCheckDigitsTypeError();
+
+ expect($error->actualType)->toBe('number');
+ });
+
+ it('sets the `expectedType` property', function () {
+ $error = new TestCpfCheckDigitsTypeError();
+
+ expect($error->expectedType)->toBe('string');
+ });
+
+ it('has a `message` property', function () {
+ $error = new TestCpfCheckDigitsTypeError();
+
+ expect($error->getMessage())->toBe('some error');
+ });
+ });
+});
+
+describe('CpfCheckDigitsInputTypeError', function () {
+ describe('when instantiated', function () {
+ it('is an instance of TypeError', function () {
+ $error = new CpfCheckDigitsInputTypeError(123, 'string');
+
+ expect($error)->toBeInstanceOf(TypeError::class);
+ });
+
+ it('is an instance of CpfCheckDigitsTypeError', function () {
+ $error = new CpfCheckDigitsInputTypeError(123, 'string');
+
+ expect($error)->toBeInstanceOf(CpfCheckDigitsTypeError::class);
+ });
+
+ it('has the correct class name', function () {
+ $error = new CpfCheckDigitsInputTypeError(123, 'string');
+
+ expect($error::class)->toBe(CpfCheckDigitsInputTypeError::class);
+ });
+
+ it('sets the `actualInput` property', function () {
+ $input = 123;
+ $error = new CpfCheckDigitsInputTypeError($input, 'string');
+
+ expect($error->actualInput)->toBe($input);
+ });
+
+ it('sets the `actualType` property', function () {
+ $error = new CpfCheckDigitsInputTypeError(123, 'string');
+
+ expect($error->actualType)->toBe('integer number');
+ });
+
+ it('sets the `expectedType` property', function () {
+ $error = new CpfCheckDigitsInputTypeError(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 = "CPF input must be of type {$expectedType}. Got {$actualType}.";
+
+ $error = new CpfCheckDigitsInputTypeError($actualInput, $expectedType);
+
+ expect($error->getMessage())->toBe($actualMessage);
+ });
+ });
+});
+
+describe('CpfCheckDigitsException', function () {
+ describe('when instantiated through a subclass', function () {
+ it('is an instance of Exception', function () {
+ $exception = new TestCpfCheckDigitsException('some error');
+
+ expect($exception)->toBeInstanceOf(\Exception::class);
+ });
+
+ it('is an instance of CpfCheckDigitsException', function () {
+ $exception = new TestCpfCheckDigitsException('some error');
+
+ expect($exception)->toBeInstanceOf(CpfCheckDigitsException::class);
+ });
+
+ it('has the correct class name', function () {
+ $exception = new TestCpfCheckDigitsException('some error');
+
+ expect($exception::class)->toBe(TestCpfCheckDigitsException::class);
+ });
+
+ it('has a `message` property', function () {
+ $exception = new TestCpfCheckDigitsException('some error');
+
+ expect($exception->getMessage())->toBe('some error');
+ });
+ });
+});
+
+describe('CpfCheckDigitsInputLengthException', function () {
+ describe('when instantiated', function () {
+ it('is an instance of Exception', function () {
+ $exception = new CpfCheckDigitsInputLengthException('1.2.3.4.5', '12345', 12, 14);
+
+ expect($exception)->toBeInstanceOf(\Exception::class);
+ });
+
+ it('is an instance of CpfCheckDigitsException', function () {
+ $exception = new CpfCheckDigitsInputLengthException('1.2.3.4.5', '12345', 12, 14);
+
+ expect($exception)->toBeInstanceOf(CpfCheckDigitsException::class);
+ });
+
+ it('has the correct class name', function () {
+ $exception = new CpfCheckDigitsInputLengthException('1.2.3.4.5', '12345', 12, 14);
+
+ expect($exception::class)->toBe(CpfCheckDigitsInputLengthException::class);
+ });
+
+ it('sets the `actualInput` property', function () {
+ $exception = new CpfCheckDigitsInputLengthException('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 CpfCheckDigitsInputLengthException('1.2.3.4.5', '12345', 12, 14);
+
+ expect($exception->evaluatedInput)->toBe('12345');
+ });
+
+ it('sets the `minExpectedLength` property', function () {
+ $exception = new CpfCheckDigitsInputLengthException('1.2.3.4.5', '12345', 12, 14);
+
+ expect($exception->minExpectedLength)->toBe(12);
+ });
+
+ it('sets the `maxExpectedLength` property', function () {
+ $exception = new CpfCheckDigitsInputLengthException('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 = 'CPF input "'.$actualInput.'" does not contain '.$minExpectedLength.' to '.$maxExpectedLength.' digits. Got '.strlen($evaluatedInput).' in "'.$evaluatedInput.'".';
+
+ $exception = new CpfCheckDigitsInputLengthException(
+ $actualInput,
+ $evaluatedInput,
+ $minExpectedLength,
+ $maxExpectedLength,
+ );
+
+ expect($exception->getMessage())->toBe($actualMessage);
+ });
+ });
+});
+
+describe('CpfCheckDigitsInputInvalidException', function () {
+ describe('when instantiated', function () {
+ it('is an instance of Exception', function () {
+ $exception = new CpfCheckDigitsInputInvalidException('1.2.3.4.5', 'repeated digits');
+
+ expect($exception)->toBeInstanceOf(\Exception::class);
+ });
+
+ it('is an instance of CpfCheckDigitsException', function () {
+ $exception = new CpfCheckDigitsInputInvalidException('1.2.3.4.5', 'repeated digits');
+
+ expect($exception)->toBeInstanceOf(CpfCheckDigitsException::class);
+ });
+
+ it('has the correct class name', function () {
+ $exception = new CpfCheckDigitsInputInvalidException('1.2.3.4.5', 'repeated digits');
+
+ expect($exception::class)->toBe(CpfCheckDigitsInputInvalidException::class);
+ });
+
+ it('sets the `actualInput` property', function () {
+ $exception = new CpfCheckDigitsInputInvalidException('1.2.3.4.5', 'repeated digits');
+
+ expect($exception->actualInput)->toBe('1.2.3.4.5');
+ });
+
+ it('sets the `reason` property', function () {
+ $exception = new CpfCheckDigitsInputInvalidException('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 = 'CPF input "'.$actualInput.'" is invalid. '.$reason;
+
+ $exception = new CpfCheckDigitsInputInvalidException($actualInput, $reason);
+
+ expect($exception->getMessage())->toBe($actualMessage);
+ });
+ });
+});
diff --git a/packages/cpf-dv/tests/Specs/Package.spec.php b/packages/cpf-dv/tests/Specs/Package.spec.php
new file mode 100644
index 0000000..59d34fd
--- /dev/null
+++ b/packages/cpf-dv/tests/Specs/Package.spec.php
@@ -0,0 +1,70 @@
+toBe(9)
+ ->and(CPF_MIN_LENGTH)->toBe(9);
+ });
+
+ it('exposes CPF_MAX_LENGTH on the class and as a global constant', function () {
+ expect(CpfCheckDigits::CPF_MAX_LENGTH)->toBe(11)
+ ->and(CPF_MAX_LENGTH)->toBe(11);
+ });
+ });
+
+ describe('when inspecting public types', function () {
+ it('exposes CpfCheckDigits as an instantiable class', function () {
+ $instance = new CpfCheckDigits('123456789');
+
+ expect($instance)->toBeInstanceOf(CpfCheckDigits::class)
+ ->and($instance->first)->toBe('0')
+ ->and($instance->second)->toBe('9')
+ ->and($instance->cpf)->toBe('12345678909');
+ });
+
+ it('exposes CpfCheckDigitsTypeError as an abstract type', function () {
+ expect((new \ReflectionClass(CpfCheckDigitsTypeError::class))->isAbstract())->toBeTrue();
+ });
+
+ it('exposes CpfCheckDigitsInputTypeError as instantiable', function () {
+ $instance = new CpfCheckDigitsInputTypeError(123, 'string');
+
+ expect($instance->actualInput)->toBe(123)
+ ->and($instance->getMessage())->toBe('CPF input must be of type string. Got integer number.');
+ });
+
+ it('exposes CpfCheckDigitsException as an abstract type', function () {
+ expect((new \ReflectionClass(CpfCheckDigitsException::class))->isAbstract())->toBeTrue();
+ });
+
+ it('exposes CpfCheckDigitsInputInvalidException as instantiable', function () {
+ $instance = new CpfCheckDigitsInputInvalidException('123', 'some reason');
+
+ expect($instance->actualInput)->toBe('123')
+ ->and($instance->reason)->toBe('some reason')
+ ->and($instance->getMessage())->toBe('CPF input "123" is invalid. some reason');
+ });
+
+ it('exposes CpfCheckDigitsInputLengthException as instantiable', function () {
+ $instance = new CpfCheckDigitsInputLengthException('x', '1', 9, 11);
+
+ expect($instance->minExpectedLength)->toBe(9)
+ ->and($instance->maxExpectedLength)->toBe(11);
+ });
+ });
+});
diff --git a/packages/utils/README.md b/packages/utils/README.md
index 10a67c1..eeca887 100644
--- a/packages/utils/README.md
+++ b/packages/utils/README.md
@@ -9,9 +9,9 @@
A PHP reusable utilities library for Lacus Solutions' packages.
-|  |  |  |  |
-|--- | --- | --- | --- |
-| Passing ✔ | Passing ✔ | Passing ✔ | Passing ✔ |
+|  |  |  |  |  |
+|--- | --- | --- | --- | --- |
+| Passing ✔ | Passing ✔ | Passing ✔ | Passing ✔ | Passing ✔ |
## Features
diff --git a/phpstan.neon b/phpstan.neon
index a78fde1..f29a902 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -7,3 +7,5 @@ parameters:
- packages/*/tests/*
- packages/*/vendor/*
ignoreErrors:
+
+ treatPhpDocTypesAsCertain: false