From db8409da68cb834bbafdb5c90f405fb69a2e13e3 Mon Sep 17 00:00:00 2001 From: Lucas Rosa Date: Wed, 21 Oct 2026 12:33:06 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20adiciona=20suporte=20a=20CNPJ=20alfanum?= =?UTF-8?q?=C3=A9rico=20nos=20campos=20de=20CPF/CNPJ?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - adiciona Util\DocumentUtil::cnpj(), que valida CNPJ numérico ou alfanumérico pelo cálculo de DV da Receita Federal e o retorna sem máscara e em maiúsculas - DocumentUtil::cpfCnpj() passa a tratar um documento com letras como CNPJ alfanumérico, em vez de descartar as letras - cobre com os vetores oficiais da Receita, testes dos models e da request do CustomerEndpoint::create - documenta as regras de documento no README e no docs/sdk.md e registra a v1.5.0 no CHANGELOG --- CHANGELOG.md | 13 ++++++ README.md | 10 +++++ docs/sdk.md | 13 ++++-- src/Util/DocumentUtil.php | 58 ++++++++++++++++++++++--- tests/DocumentUtilTest.php | 51 ++++++++++++++++++++++ tests/Endpoint/CustomerEndpointTest.php | 15 +++++++ tests/Model/CustomerTest.php | 22 ++++++++++ tests/Model/HolderTest.php | 8 ++++ tests/Model/SellerTest.php | 8 ++++ 9 files changed, 189 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a02c7fa..70081f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ O formato é baseado em [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) As versões até `v1.4.1` não têm entrada aqui: consulte as [tags](https://github.com/ipagdevs/ipag-sdk-php/tags) e o histórico de commits. +## v1.5.0 - 2026-10-21 + +### Adicionado + +- Suporte a CNPJ alfanumérico em `Customer` (`cpf_cnpj` e `tax_receipt`), `Seller` (`cpf_cnpj`) e `Holder` (`cpfCnpj`). A Receita Federal atribui esse formato às novas inscrições desde julho de 2026. + - O CNPJ é aceito com ou sem máscara, em maiúsculas ou minúsculas. + - Ele é enviado sem máscara e em maiúsculas: `12.abc.345/01de-35` vira `12ABC34501DE35`. + - Antes, um CNPJ alfanumérico era recusado com `MutatorAttributeException`. + +### Alterado + +- As letras de um documento deixam de ser descartadas, e um valor com letras só é aceito se for um CNPJ alfanumérico válido. Antes, `CPF 074.598.263-83` era aceito como `07459826383`. Agora ele é recusado. + ## v1.4.2 - 2026-10-21 ### Corrigido diff --git a/README.md b/README.md index 03bf570..30f5d62 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Este SDK, não necessariamente, reflete todos dos recursos e funcionalidades dis - [Capturar Pagamento](#capturar-pagamento) - [Cancelar Pagamento](#cancelar-pagamento) - [Cliente (Customer)](#cliente-customer) + - [CPF e CNPJ](#cpf-e-cnpj) - [Novo Cliente](#novo-cliente) - [Alterar Cliente](#alterar-cliente) - [Obter Cliente](#obter-cliente) @@ -383,6 +384,15 @@ $customer = new \Ipag\Sdk\Model\Customer() ); ``` +### CPF e CNPJ + +O `cpf_cnpj` e o `tax_receipt` do `Customer`, o `cpf_cnpj` do `Seller` e o `cpfCnpj` do `Holder` aceitam CPF ou CNPJ, com ou sem máscara, e são enviados sem máscara: + +- com 11 dígitos, o documento é CPF: `003.344.769-17` é enviado como `00334476917`; +- com 14 caracteres, é CNPJ, numérico ou alfanumérico: `12.abc.345/01de-35` é enviado como `12ABC34501DE35`. + +Envie o documento como string e com os zeros à esquerda. Um documento inválido lança `MutatorAttributeException`. + ### Novo Cliente ```php diff --git a/docs/sdk.md b/docs/sdk.md index 181b9a6..1b8abe9 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -106,10 +106,15 @@ protected function schema(SchemaBuilder $schema): Schema `Model::loadMutator` descobre o mutator com `is_callable([$this, $nome])`. - **Documento que aceita CPF ou CNPJ** (`Customer::cpf_cnpj` e `tax_receipt`, `Seller::cpf_cnpj`, `Holder::cpfCnpj`) passa por - `Util\DocumentUtil::cpfCnpj()`, e com 11 dígitos é sempre CPF. Não volte para - `asCpf(false)->or()->asCnpj(false)`. O `asCnpj` completa o valor com zeros - até 14 dígitos, e o `get()` prefere o último ramo válido, então todo CPF - iniciado em `00` (e uma parte dos outros) vira CNPJ. + `Util\DocumentUtil::cpfCnpj()`. As regras são: + - **com 11 dígitos, é sempre CPF.** Não volte para + `asCpf(false)->or()->asCnpj(false)`. O `asCnpj` completa o valor com zeros + até 14 dígitos, e o `get()` prefere o último ramo válido, então todo CPF + iniciado em `00` (e uma parte dos outros) vira CNPJ; + - **com letras, só vale CNPJ alfanumérico.** A validação é a de + `DocumentUtil::cnpj()`, que implementa o cálculo de DV da Receita + (caractere = ASCII − 48). O `asCpf`/`asCnpj` da `kubinyete/assertation` + descartam letras e não servem para isso. - **Serialização:** `jsonSerialize()` emite **todo** atributo não-`hidden`, **inclusive `null`**, e serializa as relações recursivamente. - Os models v2 (`PaymentLinkV2`, `SubscriptionV2`, `Plan`, `PlanTrial`) diff --git a/src/Util/DocumentUtil.php b/src/Util/DocumentUtil.php index 469fe24..3c7eb2e 100644 --- a/src/Util/DocumentUtil.php +++ b/src/Util/DocumentUtil.php @@ -6,25 +6,73 @@ abstract class DocumentUtil { + private const CNPJ_WEIGHTS = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]; + /** * Valida um documento que pode ser CPF ou CNPJ e o retorna sem máscara. * - * Com 11 dígitos, o documento é sempre CPF: completado com zeros à esquerda, - * um CPF pode passar também na validação de CNPJ (todo CPF iniciado em "00" - * passa). Nos outros tamanhos, o valor é completado com zeros e vale como - * CNPJ quando for um CNPJ válido, e como CPF caso contrário. + * Um documento com letras só pode ser CNPJ alfanumérico. Com 11 dígitos, o + * documento é sempre CPF: completado com zeros à esquerda, um CPF pode passar + * também na validação de CNPJ (todo CPF iniciado em "00" passa). Nos outros + * tamanhos, o valor é completado com zeros e vale como CNPJ quando for um + * CNPJ válido, e como CPF caso contrário. * * @param mixed $document * @return string|null documento sem máscara, ou null se for inválido */ public static function cpfCnpj($document): ?string { + $document = preg_replace('/[^A-Za-z0-9]/', '', strval($document)); + + if (preg_match('/[A-Za-z]/', $document)) { + return self::cnpj($document); + } + $assert = Assert::value($document)->asCpf(false); - if (strlen(preg_replace('/[^0-9]/', '', strval($document))) !== 11) { + if (strlen($document) !== 11) { $assert = $assert->or()->asCnpj(false); } return $assert->get(); } + + /** + * Valida um CNPJ, numérico ou alfanumérico, e o retorna sem máscara e em + * maiúsculas. + * + * O CNPJ alfanumérico tem 12 caracteres [A-Z0-9] e 2 dígitos verificadores + * numéricos. O cálculo do DV é o mesmo do CNPJ numérico, com o valor de cada + * caractere igual ao seu código ASCII menos 48 (documentação técnica da + * Receita Federal). + * + * @param mixed $document + * @return string|null CNPJ sem máscara, ou null se for inválido + */ + public static function cnpj($document): ?string + { + $cnpj = strtoupper(preg_replace('/[^A-Za-z0-9]/', '', strval($document))); + + if (!preg_match('/^[A-Z0-9]{12}[0-9]{2}$/', $cnpj) || $cnpj === str_repeat('0', 14)) { + return null; + } + + $base = substr($cnpj, 0, 12); + $base .= self::cnpjCheckDigit($base); + $base .= self::cnpjCheckDigit($base); + + return $base === $cnpj ? $cnpj : null; + } + + private static function cnpjCheckDigit(string $base): int + { + $weights = array_slice(self::CNPJ_WEIGHTS, -strlen($base)); + $sum = 0; + + foreach (str_split($base) as $i => $char) { + $sum += (ord($char) - ord('0')) * $weights[$i]; + } + + return $sum % 11 < 2 ? 0 : 11 - $sum % 11; + } } diff --git a/tests/DocumentUtilTest.php b/tests/DocumentUtilTest.php index e5d034e..54f59a7 100644 --- a/tests/DocumentUtilTest.php +++ b/tests/DocumentUtilTest.php @@ -40,6 +40,11 @@ public function validCpfCnpjProvider(): array 'cnpj sem o zero à esquerda' => ['1234567000195', '01234567000195'], 'cpf sem o zero à esquerda' => ['4114856790', '04114856790'], 'cpf como int' => [4114856790, '04114856790'], + + 'cnpj alfanumérico' => ['12ABC34501DE35', '12ABC34501DE35'], + 'cnpj alfanumérico com máscara' => ['12.ABC.345/01DE-35', '12ABC34501DE35'], + 'cnpj alfanumérico em minúsculas' => ['12.abc.345/01de-35', '12ABC34501DE35'], + 'cnpj alfanumérico só com letras na raiz' => ['ABCDEFGHIJKL80', 'ABCDEFGHIJKL80'], ]; } @@ -53,6 +58,52 @@ public function invalidCpfCnpjProvider(): array 'cnpj com dv inválido' => ['01234567000196'], 'dígitos repetidos' => ['11111111111'], 'vazio' => [''], + + 'cnpj alfanumérico com dv inválido' => ['ABCDEFGHIJKL81'], + 'cnpj alfanumérico com letra no primeiro dv' => ['000000000001P1'], + 'cnpj alfanumérico com letra no segundo dv' => ['0000000000019L'], + 'cnpj alfanumérico com caractere a menos' => ['12.ABC.345/01DE-3'], + 'cnpj alfanumérico com caractere a mais' => ['12.ABC.345/01DE-355'], + // letras não são mais descartadas: só valem como parte de um CNPJ alfanumérico + 'texto junto do cpf' => ['CPF 074.598.263-83'], + ]; + } + + /** + * @dataProvider validCnpjProvider + */ + public function testShouldReturnTheCnpjWithoutMask($document, string $expected) + { + $this->assertSame($expected, DocumentUtil::cnpj($document)); + } + + /** + * @dataProvider invalidCnpjProvider + */ + public function testShouldReturnNullForAnInvalidCnpj($document) + { + $this->assertNull(DocumentUtil::cnpj($document)); + } + + public function validCnpjProvider(): array + { + return [ + 'numérico' => ['01.234.567/0001-95', '01234567000195'], + 'numérico iniciado em 000' => ['00000000000191', '00000000000191'], + 'alfanumérico' => ['12.ABC.345/01DE-35', '12ABC34501DE35'], + 'alfanumérico em minúsculas' => ['abcdefghijkl80', 'ABCDEFGHIJKL80'], + ]; + } + + public function invalidCnpjProvider(): array + { + return [ + 'cpf' => ['00334476917'], + 'sem os zeros à esquerda' => ['191'], + 'dv inválido' => ['00000000000192'], + 'zerado' => ['00.000.000/0000-00'], + 'letra no dv' => ['000000000001P1'], + 'vazio' => [''], ]; } } diff --git a/tests/Endpoint/CustomerEndpointTest.php b/tests/Endpoint/CustomerEndpointTest.php index 1e63306..98a7b45 100644 --- a/tests/Endpoint/CustomerEndpointTest.php +++ b/tests/Endpoint/CustomerEndpointTest.php @@ -31,6 +31,21 @@ public function testShouldSendAnElevenDigitCpfAsCpfOnUpdate() $this->assertSame('00334476917', json_decode((string) $request->getBody(), true)['cpf_cnpj']); } + public function testShouldSendAnAlphanumericCnpjOnCreate() + { + $history = []; + $stack = HandlerStack::create(new MockHandler([new Response(200, [], '{}')])); + $stack->push(Middleware::history($history)); + $client = new IpagClientMock('test', 'AAA', IpagEnvironment::SANDBOX, '2', ['handler' => $stack]); + + $client->customer()->create(new Customer(['cpf_cnpj' => '12.abc.345/01de-35'])); + + $request = $history[0]['request']; + $this->assertSame('POST', $request->getMethod()); + $this->assertSame('/service/resources/customers', $request->getUri()->getPath()); + $this->assertSame('12ABC34501DE35', json_decode((string) $request->getBody(), true)['cpf_cnpj']); + } + public function testShouldResponseSuccess() { $this->instanceClient([ diff --git a/tests/Model/CustomerTest.php b/tests/Model/CustomerTest.php index d5c96ac..f678fd9 100644 --- a/tests/Model/CustomerTest.php +++ b/tests/Model/CustomerTest.php @@ -257,6 +257,28 @@ public function testShouldKeepAFourteenDigitCnpjOnTheCustomerCpfCnpjAndTaxReceip $this->assertEquals('01234567000195', $customer->getTaxReceipt()); } + public function testShouldAcceptAnAlphanumericCnpjOnTheCustomerCpfCnpjAndTaxReceiptProperties() + { + $customer = new \Ipag\Sdk\Model\Customer([ + 'cpf_cnpj' => '12.ABC.345/01DE-35', + 'tax_receipt' => '12.abc.345/01de-35', + ]); + + $this->assertEquals('12ABC34501DE35', $customer->getCpfCnpj()); + $this->assertEquals('12ABC34501DE35', $customer->getTaxReceipt()); + $this->assertEquals('12ABC34501DE35', $customer->jsonSerialize()['cpf_cnpj']); + $this->assertEquals('12ABC34501DE35', $customer->jsonSerialize()['tax_receipt']); + } + + public function testShouldThrowAValidationExceptionOnAnAlphanumericCnpjWithInvalidCheckDigits() + { + $customer = new \Ipag\Sdk\Model\Customer(); + + $this->expectException(MutatorAttributeException::class); + + $customer->setCpfCnpj('12.ABC.345/01DE-36'); + } + public function testShouldThrowAValidationExceptionOnTheCustomerBirthdateProperty() { $customer = new \Ipag\Sdk\Model\Customer(); diff --git a/tests/Model/HolderTest.php b/tests/Model/HolderTest.php index ef00de6..9e812ee 100644 --- a/tests/Model/HolderTest.php +++ b/tests/Model/HolderTest.php @@ -109,6 +109,14 @@ public function testShouldKeepAnElevenDigitCpfOnTheHolderCpfCnpjProperty() $this->assertEquals('01234567000195', $holder->getCpfCnpj()); } + public function testShouldAcceptAnAlphanumericCnpjOnTheHolderCpfCnpjProperty() + { + $holder = new \Ipag\Sdk\Model\Holder(['cpfCnpj' => '12.ABC.345/01DE-35']); + + $this->assertEquals('12ABC34501DE35', $holder->getCpfCnpj()); + $this->assertEquals('12ABC34501DE35', $holder->jsonSerialize()['cpfCnpj']); + } + public function testShouldThrowAValidationExceptionOnTheHolderPhoneProperty() { $holder = new \Ipag\Sdk\Model\Holder(); diff --git a/tests/Model/SellerTest.php b/tests/Model/SellerTest.php index bfcce33..c94dba2 100644 --- a/tests/Model/SellerTest.php +++ b/tests/Model/SellerTest.php @@ -185,6 +185,14 @@ public function testShouldKeepAnElevenDigitCpfOnTheSellerCpfCnpjProperty() $this->assertEquals('01234567000195', $seller->getCpfCnpj()); } + public function testShouldAcceptAnAlphanumericCnpjOnTheSellerCpfCnpjProperty() + { + $seller = new \Ipag\Sdk\Model\Seller(['cpf_cnpj' => '12.ABC.345/01DE-35']); + + $this->assertEquals('12ABC34501DE35', $seller->getCpfCnpj()); + $this->assertEquals('12ABC34501DE35', $seller->jsonSerialize()['cpf_cnpj']); + } + public function testShouldThrowAValidationExceptionOnTheSellerEmailProperty() { $seller = new \Ipag\Sdk\Model\Seller();