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
18 changes: 15 additions & 3 deletions .claude/skills/ipag-sdk-php-execution/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,15 @@ docker run --rm -u "$(id -u):$(id -g)" -e COMPOSER_HOME=/tmp/composer \
```

Se o `vendor/` estiver atrás do lock, ou com dono `root`, reinstale antes de
confiar em qualquer resultado de teste.
confiar em qualquer resultado de teste. Máquinas que já usaram o compose local
costumam ter `vendor/` e `.phpunit.cache/` com dono `root`. O cache gera um
warning de permissão no PHPUnit em 8.x. Devolva os dois ao seu usuário antes do
`install`:

```bash
docker run --rm -v "$PWD":/app composer:2 \
chown -R "$(id -u):$(id -g)" /app/vendor /app/.phpunit.cache
```

---

Expand Down Expand Up @@ -232,8 +240,12 @@ O que os testes precisam cobrir:
**Validação contra a API** (quando a task espera comportamento da API, ex.
"a API retorna o documento com a máscara X"):

1. Rode um exemplo de `examples/` contra a **sandbox**, com credenciais
editadas localmente em `examples/config.php`.
1. Rode um exemplo de `examples/` contra a **sandbox**, ou contra o ambiente
local se o usuário indicar, com credenciais editadas localmente em
`examples/config.php`. O `LOCAL` só resolve com o container na rede da API
([setup.md § Exemplos](../../../docs/setup.md#exemplos)). Para comparar
vários casos, use um script avulso no scratchpad que faça `require` do
`examples/config.php`, sem editar os exemplos versionados.
2. Desfaça a edição antes de qualquer `git add` (`git diff --quiet examples/config.php`).
3. Sem credenciais de sandbox, diga isso explicitamente e deixe a validação
pendente. Não a dê como feita com base só no teste unitário.
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,13 @@ Todas as mudanças notáveis neste projeto serão documentadas neste arquivo.
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), e este projeto adere ao [Versionamento Semântico](https://semver.org/spec/v2.0.0.html).

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.4.2 - 2026-10-21

### Corrigido

- `Customer` (`cpf_cnpj` e `tax_receipt`), `Seller` (`cpf_cnpj`) e `Holder` (`cpfCnpj`) não transformam mais um CPF em CNPJ. Um documento com 11 dígitos, com ou sem máscara, passa a ser sempre tratado como CPF e enviado com 11 dígitos. Antes, quando o CPF completado com zeros até 14 dígitos também era um CNPJ válido (todo CPF iniciado em `00` e uma parte dos demais), o SDK enviava esse CNPJ, e a API cadastrava o cliente como pessoa jurídica.
- **O valor enviado muda** para esses CPFs: `003.344.769-17` passa a ser enviado como `00334476917`, e não mais como `00000334476917`.
- Um CNPJ informado com 11 dígitos (sem os três primeiros zeros) deixa de ser completado e passa a ser validado como CPF. Envie o CNPJ com os 14 dígitos.
- Documentos com outros tamanhos seguem com o comportamento anterior. Um exemplo é o CPF passado como `int`, que perde os zeros à esquerda. Para não depender disso, envie o CPF como string de 11 dígitos.
- A atualização não corrige clientes já cadastrados com o CNPJ errado: segundo a documentação da API, o `cpf_cnpj` não pode ser alterado depois da criação do cliente.
6 changes: 6 additions & 0 deletions docs/sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ protected function schema(SchemaBuilder $schema): Schema
`$ctx->assert($cond, 'msg')`, que lançam `MutatorAttributeException`. Para
validar, o código costuma usar `Kubinyete\Assertation\Assert`.
`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.
- **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
13 changes: 13 additions & 0 deletions docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,19 @@ Para rodar um exemplo:
Alguns exemplos usam IDs fixos (ex. `examples/subscription_v2/02-*.php`) que
só existem na conta de quem os escreveu. Troque pelos da sua conta.

**Ambiente local.** `IpagEnvironment::LOCAL` (`api.ipag.test`, sem esquema) é o
nome de serviço da API dentro da rede do docker compose do ambiente local, e
não resolve no host. Para usar esse ambiente, rode o container do PHP na mesma
rede da API:

```bash
docker run --rm --network <rede-da-api> -u "$(id -u):$(id -g)" -v "$PWD":/app -w /app \
php:7.4-cli php examples/customer/00-customer-create.php
```

Para descobrir a rede, rode `docker inspect <container-da-api>`. Para conferir,
`getent hosts api.ipag.test` dentro do container precisa devolver o IP da API.

## CI

`.github/workflows/ci-tests.yml` é o único workflow.
Expand Down
5 changes: 3 additions & 2 deletions src/Model/Customer.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Ipag\Sdk\Model\Schema\Schema;
use Kubinyete\Assertation\Assert;
use Ipag\Sdk\Util\DocumentUtil;
use Ipag\Sdk\Model\Schema\Mutator;
use Ipag\Sdk\Model\Schema\SchemaBuilder;

Expand Down Expand Up @@ -255,7 +256,7 @@ protected function cpf_cnpj(): Mutator
fn($value, $ctx) =>
is_null($value) ?
$value :
Assert::value($value)->asCpf(false)->or()->asCnpj(false)->get() ?? $ctx->raise('inválido')
DocumentUtil::cpfCnpj($value) ?? $ctx->raise('inválido')
);
}

Expand All @@ -266,7 +267,7 @@ protected function tax_receipt(): Mutator
fn($value, $ctx) =>
is_null($value) ?
$value :
Assert::value($value)->asCpf(false)->or()->asCnpj(false)->get() ?? $ctx->raise('inválido')
DocumentUtil::cpfCnpj($value) ?? $ctx->raise('inválido')
);
}

Expand Down
3 changes: 2 additions & 1 deletion src/Model/Holder.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use Ipag\Sdk\Model\Schema\Schema;
use Ipag\Sdk\Model\Schema\SchemaBuilder;
use Kubinyete\Assertation\Assert;
use Ipag\Sdk\Util\DocumentUtil;

/**
* Holder Class
Expand Down Expand Up @@ -78,7 +79,7 @@ protected function cpfCnpj(): Mutator
fn($value, $ctx) =>
is_null($value) ?
$value :
Assert::value($value)->asCpf(false)->or()->asCnpj(false)->get() ?? $ctx->raise('inválido')
DocumentUtil::cpfCnpj($value) ?? $ctx->raise('inválido')
);
}

Expand Down
3 changes: 2 additions & 1 deletion src/Model/Seller.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use Ipag\Sdk\Model\Schema\Schema;
use Ipag\Sdk\Model\Schema\SchemaBuilder;
use Kubinyete\Assertation\Assert;
use Ipag\Sdk\Util\DocumentUtil;

/**
* Seller Class
Expand Down Expand Up @@ -83,7 +84,7 @@ protected function cpf_cnpj(): Mutator
fn($value, $ctx) =>
is_null($value) ?
$value :
Assert::value($value)->asCpf(false)->or()->asCnpj(false)->get() ?? $ctx->raise('inválido')
DocumentUtil::cpfCnpj($value) ?? $ctx->raise('inválido')
);
}

Expand Down
30 changes: 30 additions & 0 deletions src/Util/DocumentUtil.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

namespace Ipag\Sdk\Util;

use Kubinyete\Assertation\Assert;

abstract class DocumentUtil
{
/**
* 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.
*
* @param mixed $document
* @return string|null documento sem máscara, ou null se for inválido
*/
public static function cpfCnpj($document): ?string
{
$assert = Assert::value($document)->asCpf(false);

if (strlen(preg_replace('/[^0-9]/', '', strval($document))) !== 11) {
$assert = $assert->or()->asCnpj(false);
}

return $assert->get();
}
}
58 changes: 58 additions & 0 deletions tests/DocumentUtilTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

namespace Ipag\Sdk\Tests;

use Ipag\Sdk\Util\DocumentUtil;

class DocumentUtilTest extends BaseTestCase
{
/**
* @dataProvider validCpfCnpjProvider
*/
public function testShouldReturnTheCpfCnpjWithoutMask($document, string $expected)
{
$this->assertSame($expected, DocumentUtil::cpfCnpj($document));
}

/**
* @dataProvider invalidCpfCnpjProvider
*/
public function testShouldReturnNullForAnInvalidCpfCnpj($document)
{
$this->assertNull(DocumentUtil::cpfCnpj($document));
}

public function validCpfCnpjProvider(): array
{
return [
// completados com zeros até 14 dígitos, estes CPFs também são CNPJs válidos
'cpf iniciado em 00' => ['00334476917', '00334476917'],
'cpf iniciado em 00 com máscara' => ['003.344.769-17', '00334476917'],
'cpf iniciado em um único 0' => ['04654733930', '04654733930'],
'cpf sem zero à esquerda' => ['74032971307', '74032971307'],

'cpf sem colisão' => ['60863754295', '60863754295'],
'cnpj iniciado em 0' => ['01234567000195', '01234567000195'],
'cnpj com máscara' => ['01.234.567/0001-95', '01234567000195'],
'cnpj iniciado em 000' => ['00012345678030', '00012345678030'],

// fora de 11 e 14 dígitos, o valor continua sendo completado com zeros
'cnpj sem o zero à esquerda' => ['1234567000195', '01234567000195'],
'cpf sem o zero à esquerda' => ['4114856790', '04114856790'],
'cpf como int' => [4114856790, '04114856790'],
];
}

public function invalidCpfCnpjProvider(): array
{
return [
'cpf com dv inválido' => ['074.598.263-80'],
'cpf iniciado em 00 com dv inválido' => ['00334476918'],
// com 11 dígitos é CPF, mesmo que completado com zeros vire o CNPJ válido 00012345678030
'cnpj sem os três zeros à esquerda' => ['12345678030'],
'cnpj com dv inválido' => ['01234567000196'],
'dígitos repetidos' => ['11111111111'],
'vazio' => [''],
];
}
}
22 changes: 22 additions & 0 deletions tests/Endpoint/CustomerEndpointTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,35 @@

namespace Ipag\Sdk\Tests\Endpoint;

use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Response;
use Ipag\Sdk\Core\IpagEnvironment;
use Ipag\Sdk\Exception\HttpException;
use Ipag\Sdk\Mock\IpagClientMock;
use Ipag\Sdk\Model\Customer;
use Ipag\Sdk\Tests\IpagClient;

class CustomerEndpointTest extends IpagClient
{
public function testShouldSendAnElevenDigitCpfAsCpfOnUpdate()
{
$history = [];
$stack = HandlerStack::create(new MockHandler([new Response(200, [], '{}')]));
$stack->push(Middleware::history($history));
$client = new IpagClientMock('test', 'AAA', IpagEnvironment::SANDBOX, '2', ['handler' => $stack]);

// completado com zeros até 14 dígitos, um CPF iniciado em 00 também é um CNPJ válido
$client->customer()->update(new Customer(['cpf_cnpj' => '003.344.769-17']), 10);

$request = $history[0]['request'];
$this->assertSame('PUT', $request->getMethod());
$this->assertSame('/service/resources/customers', $request->getUri()->getPath());
$this->assertSame('id=10', $request->getUri()->getQuery());
$this->assertSame('00334476917', json_decode((string) $request->getBody(), true)['cpf_cnpj']);
}

public function testShouldResponseSuccess()
{
$this->instanceClient([
Expand Down
32 changes: 32 additions & 0 deletions tests/Model/CustomerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,38 @@ public function testShouldThrowAValidationExceptionOnTheCustomerTaxReceiptProper
$customer->setTaxReceipt('074.598.263-80');
}

public function testShouldKeepAnElevenDigitCpfOnTheCustomerCpfCnpjAndTaxReceiptProperties()
{
// completado com zeros até 14 dígitos, um CPF iniciado em 00 também é um CNPJ válido
$customer = new \Ipag\Sdk\Model\Customer([
'cpf_cnpj' => '003.344.769-17',
'tax_receipt' => '00334476917',
]);

$this->assertEquals('00334476917', $customer->getCpfCnpj());
$this->assertEquals('00334476917', $customer->getTaxReceipt());
$this->assertEquals('00334476917', $customer->jsonSerialize()['cpf_cnpj']);
$this->assertEquals('00334476917', $customer->jsonSerialize()['tax_receipt']);

$customer
->setCpfCnpj('74032971307')
->setTaxReceipt('04654733930');

$this->assertEquals('74032971307', $customer->getCpfCnpj());
$this->assertEquals('04654733930', $customer->getTaxReceipt());
}

public function testShouldKeepAFourteenDigitCnpjOnTheCustomerCpfCnpjAndTaxReceiptProperties()
{
$customer = new \Ipag\Sdk\Model\Customer([
'cpf_cnpj' => '01.234.567/0001-95',
'tax_receipt' => '01234567000195',
]);

$this->assertEquals('01234567000195', $customer->getCpfCnpj());
$this->assertEquals('01234567000195', $customer->getTaxReceipt());
}

public function testShouldThrowAValidationExceptionOnTheCustomerBirthdateProperty()
{
$customer = new \Ipag\Sdk\Model\Customer();
Expand Down
13 changes: 13 additions & 0 deletions tests/Model/HolderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,19 @@ public function testShouldThrowAValidationExceptionOnTheHolderCpfCnpjProperty()
$holder->setCpfCnpj('074.598.263-80');
}

public function testShouldKeepAnElevenDigitCpfOnTheHolderCpfCnpjProperty()
{
// completado com zeros até 14 dígitos, um CPF iniciado em 00 também é um CNPJ válido
$holder = new \Ipag\Sdk\Model\Holder(['cpfCnpj' => '003.344.769-17']);

$this->assertEquals('00334476917', $holder->getCpfCnpj());
$this->assertEquals('00334476917', $holder->jsonSerialize()['cpfCnpj']);

$holder->setCpfCnpj('01.234.567/0001-95');

$this->assertEquals('01234567000195', $holder->getCpfCnpj());
}

public function testShouldThrowAValidationExceptionOnTheHolderPhoneProperty()
{
$holder = new \Ipag\Sdk\Model\Holder();
Expand Down
13 changes: 13 additions & 0 deletions tests/Model/SellerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,19 @@ public function testShouldThrowAValidationExceptionOnTheSellerCpfCnpjProperty()
$seller->setCpfCnpj('074.598.263-80');
}

public function testShouldKeepAnElevenDigitCpfOnTheSellerCpfCnpjProperty()
{
// completado com zeros até 14 dígitos, um CPF iniciado em 00 também é um CNPJ válido
$seller = new \Ipag\Sdk\Model\Seller(['cpf_cnpj' => '003.344.769-17']);

$this->assertEquals('00334476917', $seller->getCpfCnpj());
$this->assertEquals('00334476917', $seller->jsonSerialize()['cpf_cnpj']);

$seller->setCpfCnpj('01.234.567/0001-95');

$this->assertEquals('01234567000195', $seller->getCpfCnpj());
}

public function testShouldThrowAValidationExceptionOnTheSellerEmailProperty()
{
$seller = new \Ipag\Sdk\Model\Seller();
Expand Down
Loading