Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
13 changes: 9 additions & 4 deletions docs/sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
58 changes: 53 additions & 5 deletions src/Util/DocumentUtil.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
51 changes: 51 additions & 0 deletions tests/DocumentUtilTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
];
}

Expand All @@ -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' => [''],
];
}
}
15 changes: 15 additions & 0 deletions tests/Endpoint/CustomerEndpointTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
22 changes: 22 additions & 0 deletions tests/Model/CustomerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
8 changes: 8 additions & 0 deletions tests/Model/HolderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
8 changes: 8 additions & 0 deletions tests/Model/SellerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading