diff --git a/.gitignore b/.gitignore index c06e5a9..bdfb0a1 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,8 @@ examples/rede/credentials.php # configurações locais do Claude Code (pessoais, não versionar) .claude/settings.local.json + +# certificados de mTLS (nunca versionar) +*.p12 +*.pfx +*.pem diff --git a/CLAUDE.md b/CLAUDE.md index 00681c0..26b939b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,14 +5,16 @@ Orientações para o Claude Code trabalhar neste repositório. ## O que é PHPay (`phpay-io/phpay`) é uma **biblioteca PHP** (não uma aplicação) que padroniza a -integração com gateways de pagamento brasileiros. Hoje suporta **Asaas** (as cinco -capacidades), **Mercado Pago**, **PagBank** e **Pagar.me** (clientes, cobranças, -assinaturas), **Cielo** (cobranças e recorrência), **AbacatePay** (clientes e -cobranças), **Rede** e **Efí** (cobranças). +integração com gateways de pagamento brasileiros. Hoje suporta **Asaas** e +**Woovi/OpenPix** (as cinco capacidades), **Efí** (todas menos clientes), +**Mercado Pago**, **PagBank** e **Pagar.me** (clientes, cobranças, assinaturas), +**Cielo** (cobranças e recorrência), **AbacatePay** (clientes e cobranças) e +**Rede** (cobranças). Requisitos: PHP `^8.1` para consumir a lib; `^8.2` para rodar o ambiente de dev (Pest 3 e Termwind 2 exigem 8.2+). Dependências de runtime: `ext-curl`, `ext-json`, -`guzzlehttp/guzzle ^7`. Publicado no Packagist. +`guzzlehttp/guzzle ^7.3` (a 7.3 é a primeira que entrega `.p12` ao cURL pela +extensão, e o mTLS depende disso). Publicado no Packagist. ## Arquitetura @@ -24,7 +26,7 @@ AsaasGateway / EfiGateway ──implements──▶ Interface extends │ cada método (customer/charge/pix/webhook/subscription) devolve um Resource novo ▼ Resources (Customer, Charge, Pix, Webhook, Subscription) - │ trait HasAsaasClient / HasEfiClient → PHPay\Http\HasHttpClient (get/post/put/delete) + │ trait HasAsaasClient / HasEfiClient → PHPay\Http\HasHttpClient (get/post/put/patch/delete) ▼ Requests (validação estática dos payloads antes de qualquer chamada HTTP) ``` @@ -173,8 +175,19 @@ quebra a integração, cobra o valor errado. ## Particularidades por gateway -- **Asaas** — `$sandbox` troca a base URL. Único com chaves Pix, porque é PSP. -- **Efí** — autoriza sob demanda (token em cache no gateway); `$sandbox` troca a base URL. +- **Asaas** — `$sandbox` troca a base URL. Chaves Pix próprias, porque é PSP (como Woovi e Efí). +- **Efí** — **duas APIs com as mesmas credenciais**: Cobranças (`cobrancas.api...`, + trait `HasEfiClient`, boleto em `charge()`) e Pix (`pix.api...`, trait + `HasEfiPixClient`, **só por mTLS**). Cada API tem o seu token (`getToken()` e + `getPixToken()`), em cache no gateway e renovado ao expirar, com margem de 30s. + A cobrança Pix é `pixCharge()`, **extra do gateway concreto**: `charge()` já é o + boleto e mudar o retorno quebraria a v2. **Na API Pix, valor só como `Money`** + (reais em string, `toDecimal()`), porque a API de Cobranças do mesmo gateway usa + centavos — não abra `Money|int` ali. O certificado só é exigido quando o recurso + monta o próprio client, por isso os testes injetam `pixClient` e não precisam de + arquivo. Webhook é **um por chave Pix**, endereçado pela chave. Chaves Pix: só EVP. + Rotas conferidas no SDK oficial (`efipay/sdk-php-apis-efi`), e status e campos do + Pix Automático na especificação do BACEN (`bacen/pix-api`, `openapi.yaml`). - **PagBank** — **duas APIs em hosts diferentes**: pedidos em `api.pagseguro.com`, assinaturas em `api.assinaturas.pagseguro.com`. O trait expõe `clientPagBankBoot()` e `clientPagBankSubscriptionsBoot()`; cada recurso boota o seu. **Todo valor é @@ -221,10 +234,12 @@ quebra a integração, cobra o valor errado. - `Subscription` só implementa `create()`. Listar, buscar, atualizar, cancelar, carnê e NFe seguem pendentes na API do Asaas. -- A Efí só tem autorização e cobranças; `customer`, `webhook`, `pix` e `subscription` - lançam `NotImplementedException`. -- Só o Asaas implementa `SupportsWebhooks` e `SupportsPixKeys`. Mercado Pago, PagBank e - Pagar.me registram endpoints por painel, e Pix neles é forma de pagamento. Não +- Da API Pix da Efí ficaram de fora: Pix Automático pela jornada 1 (`solicrec`, + notificação no app do pagador), webhooks de recorrência e de cobrança recorrente + (`webhookrec`, `webhookcobr`), envio de Pix e split. +- `SupportsWebhooks` e `SupportsPixKeys` só no Asaas, no Woovi e na Efí — os três são + PSP. Mercado Pago, PagBank e Pagar.me registram endpoints por painel, e Pix neles é + forma de pagamento. Não "resolva" isso criando stubs — e não declare a capacidade por causa de uma API parecida: o `/hooks` do Pagar.me lê entregas, é outra coisa, e por isso virou um recurso fora do modelo. @@ -234,6 +249,20 @@ quebra a integração, cobra o valor errado. - O CI roda a matriz em 8.2/8.3/8.4; a compatibilidade com 8.1 é garantida estaticamente pelo `phpVersion: min: 80100` do `phpstan.neon`, não por execução real. +## mTLS + +Use **`PHPay\Http\Certificate`**. O padrão do BACEN para API Pix exige mTLS em toda +requisição, inclusive a do token, e Inter, BB, Itaú, Sicoob e Sicredi seguem o mesmo +esquema — por isso o certificado é genérico, não da Efí. + +- `guzzleOptions()` devolve `['cert' => ...]`. Some isso à config do `Client` que o + trait monta; **não** passe `CURLOPT_SSLCERTTYPE` em `curl`, porque o Guzzle recente + recusa opção cURL que conflita com a dele, e ele já deduz `P12` pela extensão. +- Só `.p12` e `.pem`. Um `.pfx` é o mesmo formato, mas o Guzzle não o reconhece pela + extensão: a mensagem manda renomear. +- `fromBase64()` grava num temporário 0600, apagado ao fim do processo. +- `__debugInfo()` mascara a senha. Não crie getter para ela. + ## Segurança Biblioteca de pagamentos: nunca logar, imprimir ou commitar tokens, `access_token`, diff --git a/README.md b/README.md index d08bed3..272ad4b 100644 --- a/README.md +++ b/README.md @@ -75,13 +75,13 @@ Trocar de gateway é trocar a linha do construtor. | --- | :---: | :---: | :---: | :---: | :---: | | **Asaas** | ✅ | ✅ | ✅ | ✅ | ✅ | | **Woovi/OpenPix** | ✅ | ✅ | ✅ | ✅ | ✅ | +| **Efí** | — | ✅ | ✅ | ✅ | ✅ | | **Mercado Pago** | ✅ | ✅ | ✅ | — | — | | **PagBank** | ✅ | ✅ | ✅ | — | — | | **Pagar.me** | ✅ | ✅ | ✅ | — | — | | **AbacatePay** | ✅ | ✅ | — | — | — | | **Cielo** | — | ✅ | ✅ | — | — | | **Rede** | — | ✅ | — | — | — | -| **Efí** | — | ✅ | — | — | — | As interfaces correspondentes são `SupportsCustomers`, `SupportsCharges`, `SupportsSubscriptions`, `SupportsWebhooks` e `SupportsPixKeys`. @@ -167,9 +167,9 @@ interface que o gateway implementa **se, e só se,** oferecer: ```php use PHPay\Contracts\Capability; -$phpay = PHPay::gateway(new EfiGateway(CLIENT_ID, CLIENT_SECRET)); +$phpay = PHPay::gateway(new RedeGateway(REDE_PV, REDE_TOKEN)); -$phpay->name(); // 'Efí' +$phpay->name(); // 'Rede' $phpay->supports(Capability::SUBSCRIPTIONS); // false $phpay->capabilities(); // [Capability::CHARGES] ``` @@ -179,7 +179,7 @@ oferece: ```php $phpay->pix(); -// NotImplementedException: Efí não suporta chaves Pix. +// NotImplementedException: Rede não suporta chaves Pix. // Capacidades disponíveis: cobranças. ``` @@ -187,10 +187,10 @@ Se você segurar o **gateway concreto** em vez da facade, o erro sobe para tempo de análise — o PHPStan acusa que o método não existe naquele tipo: ```php -$efi = new EfiGateway(CLIENT_ID, CLIENT_SECRET); +$rede = new RedeGateway(REDE_PV, REDE_TOKEN); -$efi->charge(); // ✅ -$efi->pix(); // ❌ o método não existe nesse gateway +$rede->charge(); // ✅ +$rede->pix(); // ❌ o método não existe nesse gateway ``` Para injeção de dependência, tipe a capacidade em vez do gateway: @@ -244,7 +244,7 @@ que cada um faz em vez de inventar um padrão: | **Asaas** | `$sandbox` no construtor — troca a URL | | **PagBank** | `$sandbox` no construtor — troca a URL | | **Cielo** | `$sandbox` no construtor — troca **as duas** URLs | -| **Efí** | `$sandbox` no construtor — troca a URL | +| **Efí** | `$sandbox` no construtor — troca **as duas** URLs (Cobranças e Pix) | | **Rede** | `$sandbox` no construtor — troca **as duas** URLs e o caminho do token | | **Mercado Pago** | Prefixo do token (`TEST-`); host único, sem `$sandbox` | | **Pagar.me** | Prefixo da chave (`sk_test_`); host único, sem `$sandbox` | @@ -357,6 +357,11 @@ Continua funcionando, e cada gateway lê na unidade que sempre esperou: Nos que usam centavos, o PHPay recusa decimal na validação. Mas é justamente essa tabela que o `Money` torna desnecessária — **prefira o value object**. +A exceção é a [API Pix do Efí](#api-pix), que **não aceita número cru, só +`Money`**. Ela quer reais (`"100.50"`) enquanto a API de Cobranças do mesmo +gateway quer centavos, e um número solto ali seria a ambiguidade que o value +object existe para eliminar. + --- ## Gateways @@ -689,7 +694,7 @@ $phpay->deactivate($id); $phpay->reactivate($id); ### Rede -Adquirente, e a forma mais estreita da biblioteca junto com o Efí: **só +Adquirente, e a forma mais estreita da biblioteca: **só cobranças**. Não há recurso de cliente nem assinatura gerenciável — a transação tem um campo `subscription`, mas é uma flag para a adquirente, não algo que você liste ou cancele. @@ -848,21 +853,148 @@ $phpay->webhook()->getAll(); ### Efí -Só cobranças, por enquanto. O gateway **não faz chamada de rede no construtor** -— a autorização acontece na primeira vez que o token é necessário, e uma vez só. +**Duas APIs com as mesmas credenciais**, e é isso que dá ao Efí quatro das +cinco capacidades: + +| API | Host | Autenticação | Recursos | +| --- | --- | --- | --- | +| **Cobranças** | `cobrancas.api.efipay.com.br` | OAuth2 | `charge()` — boleto | +| **Pix** | `pix.api.efipay.com.br` | OAuth2 **+ mTLS** | `pix()`, `webhook()`, `subscription()`, `pixCharge()` | + +Clientes ficam de fora: nenhuma das duas APIs mantém cadastro de cliente. + +O gateway **não faz chamada de rede no construtor**. Cada API tem o seu token, +pedido na primeira vez que é necessário e **renovado sozinho quando expira** — +um gateway vivo num worker de fila não passa a tomar 401. + +#### Cobranças (boleto) ```php use PHPay\Efi\EfiGateway; +use PHPay\Support\{Customer, Money}; $gateway = new EfiGateway(CLIENT_ID, CLIENT_SECRET); $cobranca = PHPay::gateway($gateway)->charge([ - 'value' => 10050, // R$ 100,50 — o Efí usa centavos 'description' => 'Assinatura PHPay', 'expire_at' => date('Y-m-d', strtotime('+3 days')), ]) - ->setCustomer(['name' => 'Mário Lucas', 'cpf_cnpj' => '12345678901']) + ->setAmount(Money::reais('100,50')) + ->setCustomer(new Customer('Mário Lucas', '12345678909')) + ->create(); +``` + +#### API Pix + +**Toda requisição da API Pix é por mTLS**, inclusive a do token. Passe o +certificado `.p12` (ou `.pem`) da aplicação, que você baixa no painel do Efí: + +```php +$gateway = new EfiGateway(CLIENT_ID, CLIENT_SECRET, certificate: '/caminho/certificado.p12'); +``` + +Com senha, ou vindo de uma variável de ambiente — o comum em container e +serverless: + +```php +use PHPay\Http\Certificate; + +new EfiGateway(CLIENT_ID, CLIENT_SECRET, certificate: new Certificate('/caminho/certificado.p12', 'senha')); + +new EfiGateway(CLIENT_ID, CLIENT_SECRET, certificate: Certificate::fromBase64(getenv('EFI_CERTIFICATE_BASE64'))); +``` + +> O `fromBase64()` grava o certificado num arquivo temporário com permissão +> `0600`, apagado quando o processo termina. A senha nunca aparece num +> `var_dump()`. + +Certificado de homologação só funciona com `$sandbox = true`, e o de produção, +com `false`. Sem certificado, a API de Cobranças continua funcionando e a API +Pix responde com uma `ValidationException` clara, não com um erro de TLS. + +**Cobrança Pix** — imediata por padrão, com vencimento quando há `setDueDate()`: + +```php +$cobranca = $gateway->pixCharge() + ->setAmount(Money::reais('123,45')) + ->setKey('sua-chave-pix') // chave da conta Efí que recebe + ->setCustomer(new Customer('Mário Lucas', '12345678909')) + ->setDescription('Pedido 1234') + ->setExpiration(3600) // segundos ->create(); + +$qr = $gateway->pixCharge()->qrCode($cobranca['loc']['id']); +$qr['qrcode']; // copia e cola +$qr['imagemQrcode']; // PNG em base64 + +/* com vencimento: multa, juros e desconto como num boleto */ +$gateway->pixCharge() + ->setAmount(Money::reais(250)) + ->setKey('sua-chave-pix') + ->setCustomer(new Customer('Sixtec LTDA', '12345678000199')) + ->setDueDate('2026-12-31', validityAfterDue: 15) + ->create(); + +/* devolução, total ou parcial */ +$gateway->pixCharge()->refund($endToEndId, Money::reais(10)); +``` + +`pixCharge()` é um **extra do gateway concreto**, como o `webhookDeliveries()` +do Pagar.me: o `charge()` da facade já é o boleto, e mudar o retorno dele +quebraria quem está na v2. + +**Chaves Pix** — só chaves aleatórias (EVP) são gerenciáveis pela API: + +```php +$chave = PHPay::gateway($gateway)->pix()->createKey()['chave']; + +PHPay::gateway($gateway)->pix()->getAll(); +PHPay::gateway($gateway)->pix()->destroy($chave); +``` + +**Webhooks** — um por chave Pix, endereçado pela própria chave: + +```php +PHPay::gateway($gateway) + ->webhook(['chave' => $chave, 'webhookUrl' => 'https://loja.com/webhook/pix']) + ->create(); +``` + +> O mTLS vale **nos dois sentidos**: por padrão o Efí só entrega para um +> servidor que valide o certificado dele. Se o seu não consegue (hospedagem +> compartilhada, balanceador que termina o TLS), `skipMtlsChecking()` desliga a +> checagem — e aí valide a origem de outro jeito, como um `hmac` na URL. + +**Pix Automático** — o pagador autoriza uma vez no app do banco, e cada ciclo +é debitado sem nova aprovação: + +```php +use PHPay\Efi\Enums\{AccountTypeEnum, PeriodicityEnum}; + +$assinaturas = PHPay::gateway($gateway)->subscription(); + +/* 1. o location que o QR Code de autorização aponta */ +$location = $assinaturas->createLocation(); + +/* 2. a recorrência: o que o pagador autoriza */ +$recorrencia = $assinaturas + ->setCustomer(new Customer('Mário Lucas', '12345678909')) + ->setContract('CONTRATO-2026-001') // até 35 caracteres + ->setDescription('Plano mensal') + ->setAmount(Money::reais('49,90')) // ou setMinimumAmount(), para valor variável + ->setPeriodicity(PeriodicityEnum::MONTHLY, '2026-10-01') + ->allowRetries() // até 3 tentativas em 7 dias + ->setLocation($location['id']) + ->create(); + +$assinaturas->find($recorrencia['idRec'])['dadosQR']; // copia e cola para o pagador autorizar + +/* 3. a cobrança de cada ciclo */ +$assinaturas + ->setReceiver('12345-6', AccountTypeEnum::CHECKING, '0001') + ->createCharge($recorrencia['idRec'], Money::reais('49,90'), '2026-11-05'); + +$assinaturas->cancel($recorrencia['idRec']); ``` --- @@ -932,7 +1064,7 @@ Dois pontos merecem auditoria de quem vem da v1: | **AbacatePay** | ✅ | ✅ | — | — | ✅ | | **Cielo** | ✅ | — | ✅ | — | ✅ | | **Rede** | ✅ | — | — | — | 🕥 | -| **Efí** | ✅ | 🕥 | 🕥 | 🕥 | 🕥 | +| **Efí** | ✅ | — | ✅ | ✅ | ✅ | **✅** pronto · **✍️** parcial · **🕥** planejado · **—** não existe na API do gateway diff --git a/composer.json b/composer.json index e43f991..391d692 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,9 @@ "gateways", "sdk asaas", "sdk efi", + "pix", + "pix automatico", + "mtls", "phpay" ], "license": "BUSL-1.1", @@ -56,7 +59,7 @@ "php": "^8.1", "ext-curl": "*", "ext-json": "*", - "guzzlehttp/guzzle": "^7.0" + "guzzlehttp/guzzle": "^7.3" }, "require-dev": { "laravel/pint": "1.30.4", diff --git a/composer.lock b/composer.lock index e2693b3..e7cf7bf 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "da1842e8d239a95012d243ff170db51b", + "content-hash": "68f858be7e158ab8a8cc6393a22643de", "packages": [ { "name": "guzzlehttp/guzzle", diff --git a/examples/efi/credentials.example.php b/examples/efi/credentials.example.php index f873f63..956164d 100644 --- a/examples/efi/credentials.example.php +++ b/examples/efi/credentials.example.php @@ -8,5 +8,14 @@ const CLIENT_ID = ''; const CLIENT_SECRET = ''; +/* +| Só a API Pix usa: caminho do certificado .p12 de HOMOLOGAÇÃO, baixado no +| painel do Efí. Certificados (.p12, .pem, .pfx) são ignorados pelo git. +*/ +const CERTIFICATE = ''; + +/* uma chave Pix da sua conta de homologação, que recebe as cobranças */ +const PIX_KEY = ''; + const NAME = 'Mário Lucas'; const CPF_CNPJ = '00000000000'; diff --git a/examples/efi/pix.php b/examples/efi/pix.php new file mode 100644 index 0000000..445c026 --- /dev/null +++ b/examples/efi/pix.php @@ -0,0 +1,50 @@ +pixCharge() + ->setAmount(Money::reais('1,00')) + ->setKey(PIX_KEY) + ->setCustomer(Customer::make(NAME, CPF_CNPJ)) + ->setDescription('Teste PHPay') + ->setExpiration(3600) + ->create(); + + $qr = $gateway->pixCharge()->qrCode($cobranca['loc']['id']); + + echo "txid: {$cobranca['txid']}" . PHP_EOL; + echo "copia e cola: {$qr['qrcode']}" . PHP_EOL; + + /* consulta e cancela */ + $gateway->pixCharge()->find($cobranca['txid']); + $gateway->pixCharge()->cancel($cobranca['txid']); + + /* chaves aleatórias da conta */ + $phpay->pix()->getAll(); + + /* webhooks configurados */ + $phpay->webhook()->getAll(); +} catch (PHPayException $exception) { + echo $exception->getMessage() . PHP_EOL; +} diff --git a/src/Gateways/Efi/EfiGateway.php b/src/Gateways/Efi/EfiGateway.php index 3dcc9d1..aadfdc0 100644 --- a/src/Gateways/Efi/EfiGateway.php +++ b/src/Gateways/Efi/EfiGateway.php @@ -6,7 +6,13 @@ use PHPay\Efi\Interface\EfiGatewayInterface; use PHPay\Efi\Resources\Authorization\Authorization; use PHPay\Efi\Resources\Charge\Charge; -use PHPay\Exceptions\ApiException; +use PHPay\Efi\Resources\Pix\Pix; +use PHPay\Efi\Resources\PixAuthorization\PixAuthorization; +use PHPay\Efi\Resources\PixCharge\PixCharge; +use PHPay\Efi\Resources\Subscription\Subscription; +use PHPay\Efi\Resources\Webhook\Webhook; +use PHPay\Exceptions\{ApiException, ValidationException}; +use PHPay\Http\Certificate; class EfiGateway implements EfiGatewayInterface { @@ -17,22 +23,57 @@ class EfiGateway implements EfiGatewayInterface */ private ?array $token = null; + /** + * unix time after which the token is considered stale + */ + private int $tokenExpiresAt = 0; + + /** + * seconds subtracted from the advertised lifetime, so a token never + * expires between being handed out and being used + */ + private const EXPIRY_MARGIN = 30; + + /** + * token of the Pix API — a different one from the Cobranças token + * + * @var array|null + */ + private ?array $pixToken = null; + + /** + * unix time after which the Pix token is considered stale + */ + private int $pixTokenExpiresAt = 0; + + /** + * client certificate of the Pix API + */ + private ?Certificate $certificate; + /** * construct * - * no network call happens here — the token is fetched lazily on first use. + * no network call happens here — each token is fetched lazily on first use. * * @param string $clientId * @param string $clientSecret * @param bool $sandbox - * @param Client|null $client injected http client, mainly for tests + * @param Client|null $client injected http client of the Cobranças API, mainly for tests + * @param Certificate|string|null $certificate .p12/.pem of the application, or its path — + * required by the Pix API only + * @param Client|null $pixClient injected http client of the Pix API, mainly for tests + * @throws ValidationException when the certificate path is not a readable .p12/.pem */ public function __construct( private string $clientId, private string $clientSecret, private bool $sandbox = true, private ?Client $client = null, + Certificate|string|null $certificate = null, + private ?Client $pixClient = null, ) { + $this->certificate = is_string($certificate) ? new Certificate($certificate) : $certificate; } /** @@ -46,22 +87,43 @@ public function name(): string } /** - * get token, authorizing on first use. + * get token, authorizing on first use and again once it expires. + * + * the gateway may outlive the token — a queue worker keeps the same + * instance for hours — so the lifetime Efí advertises is honored. * * @return array token * @throws ApiException */ public function getToken(): array { - if ($this->token === null) { - $this->token = $this->authorize(); + if ($this->token === null || time() >= $this->tokenExpiresAt) { + $this->token = $this->authorize(); + $this->tokenExpiresAt = self::expiresAt($this->token); } return $this->token; } /** - * create charge + * get token of the Pix API, authorizing on first use and again once it + * expires. + * + * @return array token + * @throws ValidationException|ApiException + */ + public function getPixToken(): array + { + if ($this->pixToken === null || time() >= $this->pixTokenExpiresAt) { + $this->pixToken = $this->authorizePix(); + $this->pixTokenExpiresAt = self::expiresAt($this->pixToken); + } + + return $this->pixToken; + } + + /** + * create charge — a boleto, in the Cobranças API. * * @param array $charge * @return Charge @@ -77,6 +139,55 @@ public function charge(array $charge = []): Charge ); } + /** + * Pix charges — immediate or with due date, in the Pix API. + * + * an extra of the concrete gateway, like webhookDeliveries() of Pagar.me: + * charge() is already the boleto, and changing what it returns would + * break every integration on v2. + * + * @return PixCharge + * @throws ValidationException|ApiException + */ + public function pixCharge(): PixCharge + { + return new PixCharge($this->getPixToken(), $this->certificate, $this->sandbox, $this->pixClient); + } + + /** + * webhooks of the Pix API — one per Pix key. + * + * @param array $webhook `chave` and `webhookUrl` + * @return Webhook + * @throws ValidationException|ApiException + */ + public function webhook(array $webhook = []): Webhook + { + return new Webhook($this->getPixToken(), $webhook, $this->certificate, $this->sandbox, $this->pixClient); + } + + /** + * Pix keys — random keys (EVP). + * + * @return Pix + * @throws ValidationException|ApiException + */ + public function pix(): Pix + { + return new Pix($this->getPixToken(), $this->certificate, $this->sandbox, $this->pixClient); + } + + /** + * Pix Automático — recurring debit authorized once by the payer. + * + * @return Subscription + * @throws ValidationException|ApiException + */ + public function subscription(): Subscription + { + return new Subscription($this->getPixToken(), $this->certificate, $this->sandbox, $this->pixClient); + } + /** * exchange credentials for an access token. * @@ -103,4 +214,50 @@ private function authorize(): array return $token; } + + /** + * exchange credentials for an access token of the Pix API. + * + * @return array + * @throws ValidationException|ApiException + */ + private function authorizePix(): array + { + $token = (new PixAuthorization( + $this->clientId, + $this->clientSecret, + $this->certificate, + $this->sandbox, + $this->pixClient + ))->getToken(); + + if (!isset($token['access_token']) || !isset($token['token_type'])) { + throw new ApiException( + 'Efí: autorização da API Pix não retornou access_token.', + 'Efí', + 0, + $token + ); + } + + return $token; + } + + /** + * unix time at which a token stops being reused. + * + * without `expires_in` the token is kept for the life of the instance, + * which is what the gateway always did. + * + * @param array $token + * @return int + */ + private static function expiresAt(array $token): int + { + $lifetime = $token['expires_in'] ?? null; + + return is_numeric($lifetime) + ? time() + (int) $lifetime - self::EXPIRY_MARGIN + : PHP_INT_MAX; + } } diff --git a/src/Gateways/Efi/Enums/AccountTypeEnum.php b/src/Gateways/Efi/Enums/AccountTypeEnum.php new file mode 100644 index 0000000..e2be66a --- /dev/null +++ b/src/Gateways/Efi/Enums/AccountTypeEnum.php @@ -0,0 +1,14 @@ + token */ public function getToken(): array; /** - * create charge + * get token of the Pix API + * + * @return array token + */ + public function getPixToken(): array; + + /** + * create charge — a boleto, in the Cobranças API * * @param array $charge * @return Charge charge */ public function charge(array $charge = []): Charge; + + /** + * Pix charges — immediate or with due date, in the Pix API + * + * @return PixCharge + */ + public function pixCharge(): PixCharge; + + /** + * webhooks of the Pix API + * + * @param array $webhook + * @return Webhook + */ + public function webhook(array $webhook = []): Webhook; + + /** + * Pix keys + * + * @return Pix + */ + public function pix(): Pix; + + /** + * Pix Automático + * + * @return Subscription + */ + public function subscription(): Subscription; } diff --git a/src/Gateways/Efi/Requests/EfiPixChargeRequest.php b/src/Gateways/Efi/Requests/EfiPixChargeRequest.php new file mode 100644 index 0000000..8ed16df --- /dev/null +++ b/src/Gateways/Efi/Requests/EfiPixChargeRequest.php @@ -0,0 +1,110 @@ + $charge + * @param bool $withDueDate + * @param string|null $txid + * @return void + * @throws ValidationException + */ + public static function validate(array $charge, bool $withDueDate, ?string $txid = null): void + { + $messages = self::messages(); + + if ($txid !== null && preg_match(self::TXID, $txid) !== 1) { + throw ValidationException::make('Efí', $messages->txid); + } + + $amount = is_array($charge['valor'] ?? null) ? ($charge['valor']['original'] ?? null) : null; + + if (!is_string($amount) || preg_match(self::AMOUNT, $amount) !== 1) { + throw ValidationException::make('Efí', $messages->amount); + } + + if (!isset($charge['chave']) || !is_string($charge['chave']) || trim($charge['chave']) === '') { + throw ValidationException::make('Efí', $messages->key); + } + + if (isset($charge['devedor'])) { + EfiPixDebtorRequest::validate(is_array($charge['devedor']) ? $charge['devedor'] : []); + } + + if (!$withDueDate) { + return; + } + + if (!isset($charge['devedor'])) { + throw ValidationException::make('Efí', $messages->debtor); + } + + $calendar = is_array($charge['calendario'] ?? null) ? $charge['calendario'] : []; + + if (!self::isDate($calendar['dataDeVencimento'] ?? null)) { + throw ValidationException::make('Efí', $messages->dueDate); + } + } + + /** + * a txid in the BACEN pattern — 32 random hexadecimal characters. + * + * @return string + */ + public static function txid(): string + { + return bin2hex(random_bytes(16)); + } + + /** + * whether the value is a Y-m-d date that exists in the calendar. + * + * @param mixed $date + * @return bool + */ + public static function isDate(mixed $date): bool + { + if (!is_string($date)) { + return false; + } + + $parsed = \DateTimeImmutable::createFromFormat('!Y-m-d', $date); + + return $parsed !== false && $parsed->format('Y-m-d') === $date; + } + + /** + * messages for validation + * + * @return object{txid: string, amount: string, key: string, debtor: string, dueDate: string} + */ + public static function messages(): object + { + return (object) [ + 'txid' => 'O txid deve ter de 26 a 35 caracteres, somente letras e números.', + 'amount' => 'Informe o valor com setAmount(Money::reais(...)).', + 'key' => 'Informe a chave Pix que recebe o pagamento com setKey().', + 'debtor' => 'Cobrança com vencimento exige devedor: use setCustomer().', + 'dueDate' => 'O vencimento deve ser uma data válida no formato Y-m-d.', + ]; + } +} diff --git a/src/Gateways/Efi/Requests/EfiPixDebtorRequest.php b/src/Gateways/Efi/Requests/EfiPixDebtorRequest.php new file mode 100644 index 0000000..2aaecf9 --- /dev/null +++ b/src/Gateways/Efi/Requests/EfiPixDebtorRequest.php @@ -0,0 +1,76 @@ + $debtor + * @return void + * @throws ValidationException + */ + public static function validate(array $debtor): void + { + $messages = self::messages(); + + if (!isset($debtor['nome']) || !is_string($debtor['nome']) || trim($debtor['nome']) === '') { + throw ValidationException::make('Efí', $messages->name); + } + + $cpf = $debtor['cpf'] ?? null; + $cnpj = $debtor['cnpj'] ?? null; + + if (($cpf === null) === ($cnpj === null)) { + throw ValidationException::make('Efí', $messages->document); + } + + if ($cpf !== null && (!is_string($cpf) || preg_match('/^\d{11}$/', $cpf) !== 1)) { + throw ValidationException::make('Efí', $messages->cpf); + } + + if ($cnpj !== null && (!is_string($cnpj) || preg_match('/^[0-9A-Z]{14}$/', $cnpj) !== 1)) { + throw ValidationException::make('Efí', $messages->cnpj); + } + } + + /** + * messages for validation + * + * @return object{name: string, document: string, cpf: string, cnpj: string} + */ + public static function messages(): object + { + return (object) [ + 'name' => 'O devedor precisa de nome.', + 'document' => 'O devedor precisa de cpf ou de cnpj — um dos dois, nunca os dois.', + 'cpf' => 'O cpf do devedor deve ter 11 dígitos, somente números.', + 'cnpj' => 'O cnpj do devedor deve ter 14 caracteres, sem pontuação.', + ]; + } + + /** + * map the library's Customer onto the debtor the Pix API expects. + * + * @param Customer $customer + * @return array + */ + public static function fromCustomer(Customer $customer): array + { + $debtor = ['nome' => $customer->name]; + + if ($customer->document !== null) { + $debtor[$customer->isCompany() ? 'cnpj' : 'cpf'] = $customer->document; + } + + return $debtor; + } +} diff --git a/src/Gateways/Efi/Requests/EfiSubscriptionRequest.php b/src/Gateways/Efi/Requests/EfiSubscriptionRequest.php new file mode 100644 index 0000000..048142d --- /dev/null +++ b/src/Gateways/Efi/Requests/EfiSubscriptionRequest.php @@ -0,0 +1,135 @@ + $recurrence + * @return void + * @throws ValidationException + */ + public static function validate(array $recurrence): void + { + $messages = self::messages(); + $bond = is_array($recurrence['vinculo'] ?? null) ? $recurrence['vinculo'] : []; + + if (!self::isText($bond['contrato'] ?? null, 35)) { + throw ValidationException::make('Efí', $messages->contract); + } + + if (isset($bond['objeto']) && !self::isText($bond['objeto'], 35)) { + throw ValidationException::make('Efí', $messages->description); + } + + if (!isset($bond['devedor'])) { + throw ValidationException::make('Efí', $messages->debtor); + } + + EfiPixDebtorRequest::validate(is_array($bond['devedor']) ? $bond['devedor'] : []); + + $calendar = is_array($recurrence['calendario'] ?? null) ? $recurrence['calendario'] : []; + + if (!EfiPixChargeRequest::isDate($calendar['dataInicial'] ?? null)) { + throw ValidationException::make('Efí', $messages->startDate); + } + + if (isset($calendar['dataFinal']) && !EfiPixChargeRequest::isDate($calendar['dataFinal'])) { + throw ValidationException::make('Efí', $messages->endDate); + } + + $periodicity = $calendar['periodicidade'] ?? null; + + if (!is_string($periodicity) || PeriodicityEnum::tryFrom($periodicity) === null) { + throw ValidationException::make('Efí', $messages->periodicity); + } + + $amount = is_array($recurrence['valor'] ?? null) ? $recurrence['valor'] : []; + + if (!isset($amount['valorRec']) && !isset($amount['valorMinimoRecebedor'])) { + throw ValidationException::make('Efí', $messages->amount); + } + + if (!in_array($recurrence['politicaRetentativa'] ?? null, ['NAO_PERMITE', 'PERMITE_3R_7D'], true)) { + throw ValidationException::make('Efí', $messages->retry); + } + } + + /** + * validate the payload of a charge of a recurrence. + * + * @param array $charge + * @return void + * @throws ValidationException + */ + public static function validateCharge(array $charge): void + { + $messages = self::messages(); + + if (!self::isText($charge['idRec'] ?? null, 35)) { + throw ValidationException::make('Efí', $messages->recurrence); + } + + $calendar = is_array($charge['calendario'] ?? null) ? $charge['calendario'] : []; + + if (!EfiPixChargeRequest::isDate($calendar['dataDeVencimento'] ?? null)) { + throw ValidationException::make('Efí', EfiPixChargeRequest::messages()->dueDate); + } + + $receiver = is_array($charge['recebedor'] ?? null) ? $charge['recebedor'] : []; + $type = $receiver['tipoConta'] ?? null; + + if (!self::isText($receiver['conta'] ?? null, 20) + || !is_string($type) + || AccountTypeEnum::tryFrom($type) === null + ) { + throw ValidationException::make('Efí', $messages->receiver); + } + } + + /** + * messages for validation + * + * @return object{contract: string, description: string, debtor: string, startDate: string, endDate: string, periodicity: string, amount: string, retry: string, recurrence: string, receiver: string} + */ + public static function messages(): object + { + return (object) [ + 'contract' => 'Informe o contrato com setContract() — até 35 caracteres, é o que o pagador vê no app do banco.', + 'description' => 'A descrição (objeto) aceita até 35 caracteres.', + 'debtor' => 'A recorrência exige devedor: use setCustomer().', + 'startDate' => 'Informe o início com setPeriodicity(): data válida no formato Y-m-d.', + 'endDate' => 'A data final deve ser uma data válida no formato Y-m-d.', + 'periodicity' => 'Periodicidade inválida: use PeriodicityEnum.', + 'amount' => 'Informe o valor fixo com setAmount() ou o mínimo, para valor variável, com setMinimumAmount().', + 'retry' => 'A política de retentativa deve ser NAO_PERMITE ou PERMITE_3R_7D.', + 'recurrence' => 'Informe o idRec da recorrência que a cobrança pertence.', + 'receiver' => 'A cobrança de Pix Automático exige a conta recebedora: use setReceiver().', + ]; + } + + /** + * non-empty string up to a length, counted in characters. + * + * the regex counts UTF-8 characters, so "Assinatura mensal" and + * "Serviço de manutenção" measure the same way — without ext-mbstring. + * + * @param mixed $value + * @param int $max + * @return bool + */ + private static function isText(mixed $value, int $max): bool + { + return is_string($value) + && trim($value) !== '' + && preg_match('/^.{1,' . $max . '}$/us', $value) === 1; + } +} diff --git a/src/Gateways/Efi/Requests/EfiWebhookRequest.php b/src/Gateways/Efi/Requests/EfiWebhookRequest.php new file mode 100644 index 0000000..c6edc2c --- /dev/null +++ b/src/Gateways/Efi/Requests/EfiWebhookRequest.php @@ -0,0 +1,49 @@ + $webhook + * @return void + * @throws ValidationException + * @phpstan-assert array{chave: string, webhookUrl: string} $webhook + */ + public static function validate(array $webhook): void + { + $messages = self::messages(); + + if (!isset($webhook['chave']) || !is_string($webhook['chave']) || trim($webhook['chave']) === '') { + throw ValidationException::make('Efí', $messages->key); + } + + $url = $webhook['webhookUrl'] ?? null; + + if (!is_string($url) || filter_var($url, FILTER_VALIDATE_URL) === false) { + throw ValidationException::make('Efí', $messages->url); + } + + if (strtolower((string) parse_url($url, PHP_URL_SCHEME)) !== 'https') { + throw ValidationException::make('Efí', $messages->https); + } + } + + /** + * messages for validation + * + * @return object{key: string, url: string, https: string} + */ + public static function messages(): object + { + return (object) [ + 'key' => 'O campo chave é obrigatório: na Efí o webhook é configurado por chave Pix.', + 'url' => 'O campo webhookUrl é obrigatório e deve ser uma URL válida.', + 'https' => 'A Efí só entrega webhook em URL https.', + ]; + } +} diff --git a/src/Gateways/Efi/Resources/Pix/Interface/PixInterface.php b/src/Gateways/Efi/Resources/Pix/Interface/PixInterface.php new file mode 100644 index 0000000..f04ea83 --- /dev/null +++ b/src/Gateways/Efi/Resources/Pix/Interface/PixInterface.php @@ -0,0 +1,28 @@ + + */ + public function createKey(): array; + + /** + * list the random Pix keys of the account + * + * @return array + */ + public function getAll(): array; + + /** + * remove a random Pix key + * + * @param string $key + * @return bool + */ + public function destroy(string $key): bool; +} diff --git a/src/Gateways/Efi/Resources/Pix/Pix.php b/src/Gateways/Efi/Resources/Pix/Pix.php new file mode 100644 index 0000000..20be455 --- /dev/null +++ b/src/Gateways/Efi/Resources/Pix/Pix.php @@ -0,0 +1,85 @@ + $token + * @param Certificate|null $certificate required unless a client is injected + * @param bool $sandbox + * @param Client|null $client injected http client, mainly for tests + * @throws ValidationException + */ + public function __construct( + array $token, + ?Certificate $certificate = null, + private bool $sandbox = true, + ?Client $client = null, + ) { + $this->client = $client ?? $this->clientEfiPixBoot($token, $certificate); + } + + /** + * create a random Pix key (EVP). + * + * the key comes back in `chave`. + * + * @return array + * @throws ApiException + */ + public function createKey(): array + { + // no body at all: the endpoint takes none + return $this->request('POST', 'v2/gn/evp'); + } + + /** + * list the random Pix keys of the account + * + * the keys come back in `chaves`. + * + * @return array + * @throws ApiException + */ + public function getAll(): array + { + return $this->get('v2/gn/evp'); + } + + /** + * remove a random Pix key + * + * @param string $key + * @return bool + * @throws ApiException + */ + public function destroy(string $key): bool + { + return $this->delete('v2/gn/evp/' . rawurlencode($key)); + } +} diff --git a/src/Gateways/Efi/Resources/PixAuthorization/PixAuthorization.php b/src/Gateways/Efi/Resources/PixAuthorization/PixAuthorization.php new file mode 100644 index 0000000..f5f9691 --- /dev/null +++ b/src/Gateways/Efi/Resources/PixAuthorization/PixAuthorization.php @@ -0,0 +1,60 @@ +client = $client ?? $this->clientEfiPixAuthorize($clientId, $clientSecret, $certificate); + } + + /** + * exchange credentials for an access token. + * + * @return array + * @throws ApiException + */ + public function getToken(): array + { + return $this->post('oauth/token', [ + 'grant_type' => 'client_credentials', + ]); + } +} diff --git a/src/Gateways/Efi/Resources/PixCharge/Interface/PixChargeInterface.php b/src/Gateways/Efi/Resources/PixCharge/Interface/PixChargeInterface.php new file mode 100644 index 0000000..c67c270 --- /dev/null +++ b/src/Gateways/Efi/Resources/PixCharge/Interface/PixChargeInterface.php @@ -0,0 +1,163 @@ + $customer + * @return PixChargeInterface + */ + public function setCustomer(Customer|array $customer): PixChargeInterface; + + /** + * set the text shown to the payer + * + * @param string $description + * @return PixChargeInterface + */ + public function setDescription(string $description): PixChargeInterface; + + /** + * set how long an immediate charge stays payable + * + * @param int $seconds + * @return PixChargeInterface + */ + public function setExpiration(int $seconds): PixChargeInterface; + + /** + * turn the charge into a charge with due date + * + * @param string $date + * @param int $validityAfterDue + * @return PixChargeInterface + */ + public function setDueDate(string $date, int $validityAfterDue = 30): PixChargeInterface; + + /** + * set additional information shown to the payer + * + * @param array $info + * @return PixChargeInterface + */ + public function setAdditionalInfo(array $info): PixChargeInterface; + + /** + * set list filters + * + * @param array $queryParams + * @return PixChargeInterface + */ + public function setQueryParams(array $queryParams): PixChargeInterface; + + /** + * create the charge + * + * @param string|null $txid + * @return array + */ + public function create(?string $txid = null): array; + + /** + * find an immediate charge + * + * @param string $txid + * @return array + */ + public function find(string $txid): array; + + /** + * find a charge with due date + * + * @param string $txid + * @return array + */ + public function findDue(string $txid): array; + + /** + * list immediate charges + * + * @return array + */ + public function getAll(): array; + + /** + * list charges with due date + * + * @return array + */ + public function getAllDue(): array; + + /** + * revise an immediate charge + * + * @param string $txid + * @param array $data + * @return array + */ + public function update(string $txid, array $data): array; + + /** + * cancel an immediate charge + * + * @param string $txid + * @return array + */ + public function cancel(string $txid): array; + + /** + * cancel a charge with due date + * + * @param string $txid + * @return array + */ + public function cancelDue(string $txid): array; + + /** + * QR Code and copy-and-paste code of a charge + * + * @param int $locationId + * @return array + */ + public function qrCode(int $locationId): array; + + /** + * refund a received Pix + * + * @param string $endToEndId + * @param Money $amount + * @param string|null $refundId + * @return array + */ + public function refund(string $endToEndId, Money $amount, ?string $refundId = null): array; + + /** + * find a refund + * + * @param string $endToEndId + * @param string $refundId + * @return array + */ + public function findRefund(string $endToEndId, string $refundId): array; +} diff --git a/src/Gateways/Efi/Resources/PixCharge/PixCharge.php b/src/Gateways/Efi/Resources/PixCharge/PixCharge.php new file mode 100644 index 0000000..5343eec --- /dev/null +++ b/src/Gateways/Efi/Resources/PixCharge/PixCharge.php @@ -0,0 +1,366 @@ + + */ + private array $charge = []; + + /** + * @var array + */ + private array $queryParams = []; + + /** + * construct + * + * @param array $token + * @param Certificate|null $certificate required unless a client is injected + * @param bool $sandbox + * @param Client|null $client injected http client, mainly for tests + * @throws ValidationException + */ + public function __construct( + array $token, + ?Certificate $certificate = null, + private bool $sandbox = true, + ?Client $client = null, + ) { + $this->client = $client ?? $this->clientEfiPixBoot($token, $certificate); + } + + /** + * set the amount of the charge. + * + * @param Money $amount + * @return PixChargeInterface + */ + public function setAmount(Money $amount): PixChargeInterface + { + $this->charge['valor'] = ['original' => $amount->toDecimal()]; + + return $this; + } + + /** + * set the Pix key that receives the payment — one registered in the + * authenticated Efí account. + * + * @param string $key + * @return PixChargeInterface + */ + public function setKey(string $key): PixChargeInterface + { + $this->charge['chave'] = $key; + + return $this; + } + + /** + * set the debtor. + * + * optional in an immediate charge, required in one with due date. + * + * @param Customer|array $customer a Customer, or `cpf`/`cnpj` and `nome` + * @return PixChargeInterface + * @throws ValidationException + */ + public function setCustomer(Customer|array $customer): PixChargeInterface + { + if ($customer instanceof Customer) { + $customer = EfiPixDebtorRequest::fromCustomer($customer); + } + + EfiPixDebtorRequest::validate($customer); + + $this->charge['devedor'] = $customer; + + return $this; + } + + /** + * set the text shown to the payer (`solicitacaoPagador`). + * + * @param string $description + * @return PixChargeInterface + */ + public function setDescription(string $description): PixChargeInterface + { + $this->charge['solicitacaoPagador'] = $description; + + return $this; + } + + /** + * set how long an immediate charge stays payable. Efí defaults to 3600. + * + * replaces a due date set before: the last of the two wins. + * + * @param int $seconds + * @return PixChargeInterface + */ + public function setExpiration(int $seconds): PixChargeInterface + { + $this->charge['calendario'] = ['expiracao' => $seconds]; + + return $this; + } + + /** + * turn the charge into a charge with due date (`cobv`). + * + * replaces an expiration set before: the last of the two wins. + * + * @param string $date Y-m-d + * @param int $validityAfterDue days it stays payable after the due date + * @return PixChargeInterface + */ + public function setDueDate(string $date, int $validityAfterDue = 30): PixChargeInterface + { + $this->charge['calendario'] = [ + 'dataDeVencimento' => $date, + 'validadeAposVencimento' => $validityAfterDue, + ]; + + return $this; + } + + /** + * set additional information shown to the payer. + * + * @param array $info label => value, e.g. ['Pedido' => '1234'] + * @return PixChargeInterface + */ + public function setAdditionalInfo(array $info): PixChargeInterface + { + $this->charge['infoAdicionais'] = array_map( + static fn (string $name, string $value): array => ['nome' => $name, 'valor' => $value], + array_keys($info), + array_values($info), + ); + + return $this; + } + + /** + * set list filters. `inicio` and `fim` default to the last 30 days. + * + * @param array $queryParams + * @return PixChargeInterface + */ + public function setQueryParams(array $queryParams): PixChargeInterface + { + $this->queryParams = $queryParams; + + return $this; + } + + /** + * create the charge. + * + * immediate by default; with due date once setDueDate() is called. an + * immediate charge without txid lets Efí generate one; a charge with due + * date always needs a txid, so one is generated when none is given. + * + * @param string|null $txid your id for the charge, 26 to 35 letters and digits + * @return array with `txid` and `loc.id` — the QR Code comes from qrCode() + * @throws ValidationException|ApiException + */ + public function create(?string $txid = null): array + { + $calendar = $this->charge['calendario'] ?? null; + $withDueDate = is_array($calendar) && isset($calendar['dataDeVencimento']); + + EfiPixChargeRequest::validate($this->charge, $withDueDate, $txid); + + if ($withDueDate) { + $txid ??= EfiPixChargeRequest::txid(); + + return $this->put("v2/cobv/{$txid}", $this->charge); + } + + return $txid === null + ? $this->post('v2/cob', $this->charge) + : $this->put("v2/cob/{$txid}", $this->charge); + } + + /** + * find an immediate charge + * + * @param string $txid + * @return array + * @throws ApiException + */ + public function find(string $txid): array + { + return $this->get("v2/cob/{$txid}"); + } + + /** + * find a charge with due date + * + * @param string $txid + * @return array + * @throws ApiException + */ + public function findDue(string $txid): array + { + return $this->get("v2/cobv/{$txid}"); + } + + /** + * list immediate charges + * + * @return array + * @throws ApiException + */ + public function getAll(): array + { + return $this->get('v2/cob', $this->listFilters()); + } + + /** + * list charges with due date + * + * @return array + * @throws ApiException + */ + public function getAllDue(): array + { + return $this->get('v2/cobv', $this->listFilters()); + } + + /** + * revise an immediate charge. + * + * the payload goes as is: an amount here must already be a decimal + * string — use Money::toDecimal(). + * + * @param string $txid + * @param array $data + * @return array + * @throws ApiException + */ + public function update(string $txid, array $data): array + { + return $this->patch("v2/cob/{$txid}", $data); + } + + /** + * cancel an immediate charge + * + * @param string $txid + * @return array + * @throws ApiException + */ + public function cancel(string $txid): array + { + return $this->patch("v2/cob/{$txid}", ['status' => self::REMOVED]); + } + + /** + * cancel a charge with due date + * + * @param string $txid + * @return array + * @throws ApiException + */ + public function cancelDue(string $txid): array + { + return $this->patch("v2/cobv/{$txid}", ['status' => self::REMOVED]); + } + + /** + * QR Code of a charge: `qrcode` (copy and paste), `imagemQrcode` (base64 + * PNG) and `linkVisualizacao`. + * + * @param int $locationId the `loc.id` that create() returns + * @return array + * @throws ApiException + */ + public function qrCode(int $locationId): array + { + return $this->get("v2/loc/{$locationId}/qrcode"); + } + + /** + * refund a received Pix, fully or in part. + * + * @param string $endToEndId the `endToEndId` of the Pix received + * @param Money $amount + * @param string|null $refundId your id for the refund; generated when null + * @return array + * @throws ApiException + */ + public function refund(string $endToEndId, Money $amount, ?string $refundId = null): array + { + $refundId ??= EfiPixChargeRequest::txid(); + + return $this->put("v2/pix/{$endToEndId}/devolucao/{$refundId}", [ + 'valor' => $amount->toDecimal(), + ]); + } + + /** + * find a refund + * + * @param string $endToEndId + * @param string $refundId + * @return array + * @throws ApiException + */ + public function findRefund(string $endToEndId, string $refundId): array + { + return $this->get("v2/pix/{$endToEndId}/devolucao/{$refundId}"); + } + + /** + * list filters, with the required period defaulting to the last 30 days. + * + * @return array + */ + private function listFilters(): array + { + return $this->queryParams + [ + 'inicio' => gmdate('Y-m-d\TH:i:s\Z', strtotime('-30 days')), + 'fim' => gmdate('Y-m-d\TH:i:s\Z'), + ]; + } +} diff --git a/src/Gateways/Efi/Resources/Subscription/Interface/SubscriptionInterface.php b/src/Gateways/Efi/Resources/Subscription/Interface/SubscriptionInterface.php new file mode 100644 index 0000000..da95a77 --- /dev/null +++ b/src/Gateways/Efi/Resources/Subscription/Interface/SubscriptionInterface.php @@ -0,0 +1,182 @@ + $customer + * @return SubscriptionInterface + */ + public function setCustomer(Customer|array $customer): SubscriptionInterface; + + /** + * set the contract the recurrence is bound to + * + * @param string $contract + * @return SubscriptionInterface + */ + public function setContract(string $contract): SubscriptionInterface; + + /** + * set what is being charged + * + * @param string $description + * @return SubscriptionInterface + */ + public function setDescription(string $description): SubscriptionInterface; + + /** + * set a fixed amount for every cycle + * + * @param Money $amount + * @return SubscriptionInterface + */ + public function setAmount(Money $amount): SubscriptionInterface; + + /** + * set the minimum of a variable amount + * + * @param Money $amount + * @return SubscriptionInterface + */ + public function setMinimumAmount(Money $amount): SubscriptionInterface; + + /** + * set the calendar of the recurrence + * + * @param PeriodicityEnum $periodicity + * @param string $startDate + * @param string|null $endDate + * @return SubscriptionInterface + */ + public function setPeriodicity( + PeriodicityEnum $periodicity, + string $startDate, + ?string $endDate = null + ): SubscriptionInterface; + + /** + * allow retries after a failed charge + * + * @param bool $allow + * @return SubscriptionInterface + */ + public function allowRetries(bool $allow = true): SubscriptionInterface; + + /** + * bind the recurrence to a location, for the QR Code journey + * + * @param int $locationId + * @return SubscriptionInterface + */ + public function setLocation(int $locationId): SubscriptionInterface; + + /** + * activate the recurrence together with an immediate charge + * + * @param string $txid + * @return SubscriptionInterface + */ + public function setActivationTxid(string $txid): SubscriptionInterface; + + /** + * set the account that receives the charges + * + * @param string $account + * @param AccountTypeEnum $type + * @param string|null $branch + * @return SubscriptionInterface + */ + public function setReceiver( + string $account, + AccountTypeEnum $type = AccountTypeEnum::CHECKING, + ?string $branch = null + ): SubscriptionInterface; + + /** + * set list filters + * + * @param array $queryParams + * @return SubscriptionInterface + */ + public function setQueryParams(array $queryParams): SubscriptionInterface; + + /** + * create the recurrence + * + * @return array + */ + public function create(): array; + + /** + * find a recurrence + * + * @param string $id + * @return array + */ + public function find(string $id): array; + + /** + * list recurrences + * + * @return array + */ + public function getAll(): array; + + /** + * revise a recurrence + * + * @param string $id + * @param array $data + * @return array + */ + public function update(string $id, array $data): array; + + /** + * cancel a recurrence + * + * @param string $id + * @return array + */ + public function cancel(string $id): array; + + /** + * create a location for the QR Code journey + * + * @return array + */ + public function createLocation(): array; + + /** + * create the charge of one cycle + * + * @param string $id + * @param Money $amount + * @param string $dueDate + * @param array $extra + * @return array + */ + public function createCharge(string $id, Money $amount, string $dueDate, array $extra = []): array; + + /** + * find the charge of a cycle + * + * @param string $txid + * @return array + */ + public function findCharge(string $txid): array; + + /** + * cancel the charge of a cycle + * + * @param string $txid + * @return array + */ + public function cancelCharge(string $txid): array; +} diff --git a/src/Gateways/Efi/Resources/Subscription/Subscription.php b/src/Gateways/Efi/Resources/Subscription/Subscription.php new file mode 100644 index 0000000..f856a99 --- /dev/null +++ b/src/Gateways/Efi/Resources/Subscription/Subscription.php @@ -0,0 +1,400 @@ + + */ + private array $subscription = []; + + /** + * the `vinculo`: contract, debtor and object + * + * @var array + */ + private array $bond = []; + + /** + * the `valor`: fixed or minimum amount + * + * @var array + */ + private array $amount = []; + + /** + * @var array + */ + private array $receiver = []; + + /** + * @var array + */ + private array $queryParams = []; + + /** + * construct + * + * @param array $token + * @param Certificate|null $certificate required unless a client is injected + * @param bool $sandbox + * @param Client|null $client injected http client, mainly for tests + * @throws ValidationException + */ + public function __construct( + array $token, + ?Certificate $certificate = null, + private bool $sandbox = true, + ?Client $client = null, + ) { + $this->client = $client ?? $this->clientEfiPixBoot($token, $certificate); + } + + /** + * set the debtor + * + * @param Customer|array $customer a Customer, or `cpf`/`cnpj` and `nome` + * @return SubscriptionInterface + * @throws ValidationException + */ + public function setCustomer(Customer|array $customer): SubscriptionInterface + { + if ($customer instanceof Customer) { + $customer = EfiPixDebtorRequest::fromCustomer($customer); + } + + EfiPixDebtorRequest::validate($customer); + + $this->bond['devedor'] = $customer; + + return $this; + } + + /** + * set the contract the recurrence is bound to — your id for it, shown to + * the payer. up to 35 characters. + * + * @param string $contract + * @return SubscriptionInterface + */ + public function setContract(string $contract): SubscriptionInterface + { + $this->bond['contrato'] = $contract; + + return $this; + } + + /** + * set what is being charged (`objeto`), up to 35 characters. + * + * @param string $description + * @return SubscriptionInterface + */ + public function setDescription(string $description): SubscriptionInterface + { + $this->bond['objeto'] = $description; + + return $this; + } + + /** + * set a fixed amount for every cycle + * + * @param Money $amount + * @return SubscriptionInterface + */ + public function setAmount(Money $amount): SubscriptionInterface + { + $this->amount['valorRec'] = $amount->toDecimal(); + + return $this; + } + + /** + * set the minimum of a variable amount — each charge brings its own. + * + * @param Money $amount + * @return SubscriptionInterface + */ + public function setMinimumAmount(Money $amount): SubscriptionInterface + { + $this->amount['valorMinimoRecebedor'] = $amount->toDecimal(); + + return $this; + } + + /** + * set the calendar of the recurrence + * + * @param PeriodicityEnum $periodicity + * @param string $startDate Y-m-d + * @param string|null $endDate Y-m-d; null for no end + * @return SubscriptionInterface + */ + public function setPeriodicity( + PeriodicityEnum $periodicity, + string $startDate, + ?string $endDate = null + ): SubscriptionInterface { + $this->subscription['calendario'] = array_filter([ + 'dataInicial' => $startDate, + 'dataFinal' => $endDate, + 'periodicidade' => $periodicity->value, + ], static fn (?string $value): bool => $value !== null); + + return $this; + } + + /** + * allow retries after a failed charge — up to 3 in 7 days. off by default. + * + * @param bool $allow + * @return SubscriptionInterface + */ + public function allowRetries(bool $allow = true): SubscriptionInterface + { + $this->subscription['politicaRetentativa'] = $allow ? 'PERMITE_3R_7D' : 'NAO_PERMITE'; + + return $this; + } + + /** + * bind the recurrence to a location, for the QR Code journey. + * + * @param int $locationId the `id` that createLocation() returns + * @return SubscriptionInterface + */ + public function setLocation(int $locationId): SubscriptionInterface + { + $this->subscription['loc'] = $locationId; + + return $this; + } + + /** + * activate the recurrence together with an immediate charge: the payer + * pays the first cycle and authorizes the next ones in one QR Code. + * + * @param string $txid of an immediate charge created with pixCharge() + * @return SubscriptionInterface + */ + public function setActivationTxid(string $txid): SubscriptionInterface + { + $this->subscription['ativacao'] = ['dadosJornada' => ['txid' => $txid]]; + + return $this; + } + + /** + * set the account that receives the charges of createCharge(). + * + * @param string $account account number, with digit + * @param AccountTypeEnum $type + * @param string|null $branch agency, when the account has one + * @return SubscriptionInterface + */ + public function setReceiver( + string $account, + AccountTypeEnum $type = AccountTypeEnum::CHECKING, + ?string $branch = null + ): SubscriptionInterface { + $this->receiver = array_filter([ + 'agencia' => $branch, + 'conta' => $account, + 'tipoConta' => $type->value, + ], static fn (?string $value): bool => $value !== null); + + return $this; + } + + /** + * set list filters. `inicio` and `fim` default to the last 30 days. + * + * @param array $queryParams + * @return SubscriptionInterface + */ + public function setQueryParams(array $queryParams): SubscriptionInterface + { + $this->queryParams = $queryParams; + + return $this; + } + + /** + * create the recurrence + * + * @return array with `idRec` + * @throws ValidationException|ApiException + */ + public function create(): array + { + $recurrence = array_filter([ + 'vinculo' => $this->bond, + 'valor' => $this->amount, + ]) + $this->subscription + ['politicaRetentativa' => 'NAO_PERMITE']; + + EfiSubscriptionRequest::validate($recurrence); + + return $this->post('v2/rec', $recurrence); + } + + /** + * find a recurrence — with its status and, when bound to a location, the + * copy-and-paste code in `dadosQR`. + * + * @param string $id the `idRec` + * @return array + * @throws ApiException + */ + public function find(string $id): array + { + return $this->get("v2/rec/{$id}"); + } + + /** + * list recurrences + * + * @return array + * @throws ApiException + */ + public function getAll(): array + { + return $this->get('v2/rec', $this->queryParams + [ + 'inicio' => gmdate('Y-m-d\TH:i:s\Z', strtotime('-30 days')), + 'fim' => gmdate('Y-m-d\TH:i:s\Z'), + ]); + } + + /** + * revise a recurrence. + * + * the payload goes as is: an amount here must already be a decimal + * string — use Money::toDecimal(). + * + * @param string $id the `idRec` + * @param array $data + * @return array + * @throws ApiException + */ + public function update(string $id, array $data): array + { + return $this->patch("v2/rec/{$id}", $data); + } + + /** + * cancel a recurrence + * + * @param string $id the `idRec` + * @return array + * @throws ApiException + */ + public function cancel(string $id): array + { + return $this->patch("v2/rec/{$id}", ['status' => self::CANCELLED]); + } + + /** + * create a location for the QR Code journey + * + * @return array with `id`, for setLocation() + * @throws ApiException + */ + public function createLocation(): array + { + // no body at all: the endpoint takes none + return $this->request('POST', 'v2/locrec'); + } + + /** + * create the charge of one cycle. + * + * @param string $id the `idRec` + * @param Money $amount + * @param string $dueDate Y-m-d + * @param array $extra other fields, e.g. `infoAdicional` or `ajusteDiaUtil` + * @return array with `txid` + * @throws ValidationException|ApiException + */ + public function createCharge(string $id, Money $amount, string $dueDate, array $extra = []): array + { + $charge = array_replace([ + 'idRec' => $id, + 'calendario' => ['dataDeVencimento' => $dueDate], + 'valor' => ['original' => $amount->toDecimal()], + 'ajusteDiaUtil' => true, + 'recebedor' => $this->receiver, + ], $extra); + + EfiSubscriptionRequest::validateCharge($charge); + + return $this->post('v2/cobr', $charge); + } + + /** + * find the charge of a cycle + * + * @param string $txid + * @return array + * @throws ApiException + */ + public function findCharge(string $txid): array + { + return $this->get("v2/cobr/{$txid}"); + } + + /** + * cancel the charge of a cycle — only before the day of its first + * settlement attempt. + * + * @param string $txid + * @return array + * @throws ApiException + */ + public function cancelCharge(string $txid): array + { + return $this->patch("v2/cobr/{$txid}", ['status' => self::CANCELLED]); + } +} diff --git a/src/Gateways/Efi/Resources/Webhook/Interface/WebhookInterface.php b/src/Gateways/Efi/Resources/Webhook/Interface/WebhookInterface.php new file mode 100644 index 0000000..299eb94 --- /dev/null +++ b/src/Gateways/Efi/Resources/Webhook/Interface/WebhookInterface.php @@ -0,0 +1,53 @@ + $webhook + * @return array + */ + public function create(array $webhook = []): array; + + /** + * skip the mTLS check Efí makes on your server + * + * @param bool $skip + * @return WebhookInterface + */ + public function skipMtlsChecking(bool $skip = true): WebhookInterface; + + /** + * find the webhook of a Pix key + * + * @param string $key + * @return array + */ + public function find(string $key): array; + + /** + * list webhooks + * + * @return array + */ + public function getAll(): array; + + /** + * remove the webhook of a Pix key + * + * @param string $key + * @return bool + */ + public function destroy(string $key): bool; + + /** + * set list filters + * + * @param array $queryParams + * @return WebhookInterface + */ + public function setQueryParams(array $queryParams): WebhookInterface; +} diff --git a/src/Gateways/Efi/Resources/Webhook/Webhook.php b/src/Gateways/Efi/Resources/Webhook/Webhook.php new file mode 100644 index 0000000..3a2b259 --- /dev/null +++ b/src/Gateways/Efi/Resources/Webhook/Webhook.php @@ -0,0 +1,152 @@ + + */ + private array $queryParams = []; + + /** + * whether Efí skips the mTLS check on the webhook server + */ + private bool $skipMtlsChecking = false; + + /** + * construct + * + * @param array $token + * @param array $webhook `chave` and `webhookUrl` + * @param Certificate|null $certificate required unless a client is injected + * @param bool $sandbox + * @param Client|null $client injected http client, mainly for tests + * @throws ValidationException + */ + public function __construct( + array $token, + private array $webhook = [], + ?Certificate $certificate = null, + private bool $sandbox = true, + ?Client $client = null, + ) { + $this->client = $client ?? $this->clientEfiPixBoot($token, $certificate); + } + + /** + * configure the webhook of a Pix key. + * + * configuring again replaces the URL: there is one webhook per key. + * + * @param array $webhook overrides the payload given to the gateway + * @return array + * @throws ValidationException|ApiException + */ + public function create(array $webhook = []): array + { + if (!empty($webhook)) { + $this->webhook = $webhook; + } + + $webhook = $this->webhook; + + EfiWebhookRequest::validate($webhook); + + return $this->put( + 'v2/webhook/' . rawurlencode($webhook['chave']), + ['webhookUrl' => $webhook['webhookUrl']], + $this->skipMtlsChecking ? ['x-skip-mtls-checking' => 'true'] : [], + ); + } + + /** + * skip the mTLS check Efí makes on your server before delivering. + * + * @param bool $skip + * @return WebhookInterface + */ + public function skipMtlsChecking(bool $skip = true): WebhookInterface + { + $this->skipMtlsChecking = $skip; + + return $this; + } + + /** + * find the webhook of a Pix key + * + * @param string $key + * @return array + * @throws ApiException + */ + public function find(string $key): array + { + return $this->get('v2/webhook/' . rawurlencode($key)); + } + + /** + * list webhooks. `inicio` and `fim` default to the last 30 days. + * + * @return array + * @throws ApiException + */ + public function getAll(): array + { + return $this->get('v2/webhook', $this->queryParams + [ + 'inicio' => gmdate('Y-m-d\TH:i:s\Z', strtotime('-30 days')), + 'fim' => gmdate('Y-m-d\TH:i:s\Z'), + ]); + } + + /** + * remove the webhook of a Pix key + * + * @param string $key + * @return bool + * @throws ApiException + */ + public function destroy(string $key): bool + { + return $this->delete('v2/webhook/' . rawurlencode($key)); + } + + /** + * set list filters + * + * @param array $queryParams + * @return WebhookInterface + */ + public function setQueryParams(array $queryParams): WebhookInterface + { + $this->queryParams = $queryParams; + + return $this; + } +} diff --git a/src/Gateways/Efi/Traits/HasEfiPixClient.php b/src/Gateways/Efi/Traits/HasEfiPixClient.php new file mode 100644 index 0000000..27a9008 --- /dev/null +++ b/src/Gateways/Efi/Traits/HasEfiPixClient.php @@ -0,0 +1,116 @@ + $this->pixBaseUri(), + 'auth' => [$clientId, $clientSecret], + 'headers' => [ + 'content-type' => 'application/json', + ], + ] + $this->requireCertificate($certificate)->guzzleOptions()); + } + + /** + * boot client + * + * @param array $token + * @param Certificate|null $certificate + * @return Client + * @throws ValidationException + */ + protected function clientEfiPixBoot(array $token, ?Certificate $certificate): Client + { + $accessToken = $token['access_token'] ?? null; + $tokenType = $token['token_type'] ?? null; + + if (!is_string($accessToken) || !is_string($tokenType)) { + throw ValidationException::make( + 'Efí', + 'Token inválido: access_token e token_type devem ser strings.' + ); + } + + return new Client([ + 'base_uri' => $this->pixBaseUri(), + 'headers' => [ + 'Authorization' => "{$tokenType} {$accessToken}", + 'content-type' => 'application/json', + ], + ] + $this->requireCertificate($certificate)->guzzleOptions()); + } + + /** + * base uri of the Pix API for the current environment + * + * @return string + */ + protected function pixBaseUri(): string + { + return $this->sandbox + ? 'https://pix-h.api.efipay.com.br/' + : 'https://pix.api.efipay.com.br/'; + } + + /** + * gateway name used in exception messages. + * + * @return string + */ + protected function gatewayName(): string + { + return 'Efí'; + } + + /** + * the certificate, or a clear error instead of a TLS handshake failure. + * + * @param Certificate|null $certificate + * @return Certificate + * @throws ValidationException + */ + private function requireCertificate(?Certificate $certificate): Certificate + { + if ($certificate === null) { + throw ValidationException::make( + 'Efí', + 'a API Pix exige o certificado .p12 ou .pem da aplicação (mTLS). ' + . 'Passe-o no construtor: new EfiGateway($id, $secret, certificate: \'/caminho/certificado.p12\').' + ); + } + + return $certificate; + } +} diff --git a/src/Http/Certificate.php b/src/Http/Certificate.php new file mode 100644 index 0000000..f46a8b7 --- /dev/null +++ b/src/Http/Certificate.php @@ -0,0 +1,174 @@ +notFound, $path)); + } + + if (!in_array(self::extensionOf($path), self::FORMATS, true)) { + throw ValidationException::make('Certificado', $messages->format); + } + } + + /** + * build a certificate from its base64 content. + * + * for containers and serverless, where the certificate travels in an + * environment variable instead of a file. the content is written to a + * private temporary file (0600) removed when the process ends. + * + * @param string $content base64 of the .p12 or .pem file + * @param string|null $passphrase + * @param string $format 'p12' or 'pem' + * @return self + * @throws ValidationException + */ + public static function fromBase64( + #[\SensitiveParameter] + string $content, + #[\SensitiveParameter] + ?string $passphrase = null, + string $format = 'p12', + ): self { + $messages = self::messages(); + $format = strtolower($format); + + if (!in_array($format, self::FORMATS, true)) { + throw ValidationException::make('Certificado', $messages->format); + } + + $bytes = base64_decode($content, true); + + if ($bytes === false || $bytes === '') { + throw ValidationException::make('Certificado', $messages->base64); + } + + $temporary = tempnam(sys_get_temp_dir(), 'phpay-cert-'); + + if ($temporary === false) { + throw ValidationException::make('Certificado', $messages->temporary); + } + + // tempnam() creates the file as 0600; rename() keeps the mode and adds + // the extension Guzzle reads to tell cURL the format. + $path = "{$temporary}.{$format}"; + + if (!rename($temporary, $path) || file_put_contents($path, $bytes, LOCK_EX) === false) { + throw ValidationException::make('Certificado', $messages->temporary); + } + + register_shutdown_function(static function () use ($path): void { + if (is_file($path)) { + unlink($path); + } + }); + + return new self($path, $passphrase); + } + + /** + * path to the certificate file + * + * @return string + */ + public function path(): string + { + return $this->path; + } + + /** + * 'p12' or 'pem' + * + * @return string + */ + public function format(): string + { + return self::extensionOf($this->path); + } + + /** + * the Guzzle request options that present this certificate. + * + * @return array{cert: string|array{0: string, 1: string}} + */ + public function guzzleOptions(): array + { + return [ + 'cert' => $this->passphrase === null ? $this->path : [$this->path, $this->passphrase], + ]; + } + + /** + * keep the passphrase out of var_dump() and print_r(). + * + * @return array{path: string, passphrase: string|null} + */ + public function __debugInfo(): array + { + return [ + 'path' => $this->path, + 'passphrase' => $this->passphrase === null ? null : '********', + ]; + } + + /** + * messages for validation + * + * @return object{notFound: string, format: string, base64: string, temporary: string} + */ + public static function messages(): object + { + return (object) [ + 'notFound' => 'arquivo não encontrado ou sem permissão de leitura em %s.', + 'format' => 'use o arquivo .p12 ou .pem emitido pelo PSP. Um .pfx é o mesmo formato do .p12: basta renomear.', + 'base64' => 'o conteúdo informado não é um base64 válido.', + 'temporary' => 'não foi possível gravar o certificado em um arquivo temporário.', + ]; + } + + /** + * lowercase extension of a path + * + * @param string $path + * @return string + */ + private static function extensionOf(string $path): string + { + return strtolower(pathinfo($path, PATHINFO_EXTENSION)); + } +} diff --git a/src/Http/HasHttpClient.php b/src/Http/HasHttpClient.php index e95dd52..6355c8f 100644 --- a/src/Http/HasHttpClient.php +++ b/src/Http/HasHttpClient.php @@ -60,12 +60,32 @@ protected function post(string $endpoint, array $data = [], array $headers = []) * * @param string $endpoint * @param array $data + * @param array $headers extra headers for this request only + * @return array + * @throws ApiException + */ + protected function put(string $endpoint, array $data = [], array $headers = []): array + { + $options = ['json' => $data]; + + if (!empty($headers)) { + $options['headers'] = $headers; + } + + return $this->request('PUT', $endpoint, $options); + } + + /** + * patch data + * + * @param string $endpoint + * @param array $data * @return array * @throws ApiException */ - protected function put(string $endpoint, array $data = []): array + protected function patch(string $endpoint, array $data = []): array { - return $this->request('PUT', $endpoint, ['json' => $data]); + return $this->request('PATCH', $endpoint, ['json' => $data]); } /** diff --git a/src/Support/Money.php b/src/Support/Money.php index 901d88a..cfdbeac 100644 --- a/src/Support/Money.php +++ b/src/Support/Money.php @@ -97,6 +97,19 @@ public function toReais(): float return round($this->cents / 100, 2); } + /** + * the amount in reais as a decimal string with a dot: "1234.56". + * + * the shape the BACEN Pix standard expects in `valor.original`. built with + * integer arithmetic, so no float ever gets between the cents and the text. + * + * @return string + */ + public function toDecimal(): string + { + return intdiv($this->cents, 100) . '.' . str_pad((string) ($this->cents % 100), 2, '0', STR_PAD_LEFT); + } + /** * multiply by a whole number of units — a line of N identical products. * diff --git a/tests/Pest.php b/tests/Pest.php index c750cd8..65753e4 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -3,6 +3,7 @@ use GuzzleHttp\{Client, HandlerStack, Middleware}; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\Psr7\Response; +use PHPay\Efi\EfiGateway; /* |-------------------------------------------------------------------------- @@ -170,3 +171,50 @@ function wooviClient(array $responses, array &$history = []): Client { return mockClient($responses, $history, 'https://api.woovi-sandbox.com/'); } + +/** + * mock client already pointed at the Efí Pix API sandbox host. + * + * @param array $responses + * @param array $history filled with the recorded transactions + * @return Client + */ +function efiPixClient(array $responses, array &$history = []): Client +{ + return mockClient($responses, $history, 'https://pix-h.api.efipay.com.br/'); +} + +/** + * token response of the Efí Pix API. + * + * @param string $token + * @param int $expiresIn + * @return Response + */ +function efiPixToken(string $token = 'pix_tok', int $expiresIn = 3600): Response +{ + return jsonResponse([ + 'access_token' => $token, + 'token_type' => 'Bearer', + 'expires_in' => $expiresIn, + 'scope' => 'cob.write cob.read pix.write pix.read webhook.write webhook.read', + ]); +} + +/** + * Efí gateway whose Pix API answers with a token first, then the responses. + * + * @param array $responses + * @param array $history filled with the recorded transactions + * @return EfiGateway + */ +function efiPixGateway(array $responses, array &$history = []): EfiGateway +{ + return new EfiGateway( + 'client-id', + 'client-secret', + true, + mockClient([]), + pixClient: efiPixClient([efiPixToken(), ...$responses], $history), + ); +} diff --git a/tests/Unit/CapabilityTest.php b/tests/Unit/CapabilityTest.php index a529176..ec26a72 100644 --- a/tests/Unit/CapabilityTest.php +++ b/tests/Unit/CapabilityTest.php @@ -32,9 +32,14 @@ expect($asaas->supports(Capability::PIX_KEYS))->toBeTrue() ->and($asaas->capabilities())->toHaveCount(5) - ->and($efi->supports(Capability::PIX_KEYS))->toBeFalse() - ->and($efi->supports(Capability::CHARGES))->toBeTrue() - ->and($efi->capabilities())->toBe([Capability::CHARGES]); + ->and($efi->supports(Capability::PIX_KEYS))->toBeTrue() + ->and($efi->supports(Capability::CUSTOMERS))->toBeFalse() + ->and($efi->capabilities())->toBe([ + Capability::CHARGES, + Capability::WEBHOOKS, + Capability::PIX_KEYS, + Capability::SUBSCRIPTIONS, + ]); })->group('phpay'); it('expõe o nome do gateway através da facade', function () { @@ -46,12 +51,11 @@ $phpay = PHPay::gateway(new EfiGateway('id', 'secret', true, mockClient([]))); try { - $phpay->pix(); + $phpay->customer(); $this->fail('NotImplementedException não foi lançada'); } catch (NotImplementedException $exception) { expect($exception->getMessage()) - ->toContain('Efí') - ->toContain('chaves Pix') - ->toContain('cobranças'); + ->toContain('Efí não suporta clientes') + ->toContain('cobranças, webhooks, chaves Pix, assinaturas'); } })->group('phpay'); diff --git a/tests/Unit/Efi/EfiGatewayTest.php b/tests/Unit/Efi/EfiGatewayTest.php index f5bc7a8..c56d365 100644 --- a/tests/Unit/Efi/EfiGatewayTest.php +++ b/tests/Unit/Efi/EfiGatewayTest.php @@ -29,6 +29,34 @@ ->and((string) $history[0]['request']->getUri())->toEndWith('v1/authorize'); })->group('efi'); +it('reaproveita o token enquanto ele não expira', function () { + $history = []; + $client = mockClient([ + jsonResponse(['access_token' => 'tok_1', 'token_type' => 'Bearer', 'expires_in' => 600]), + ], $history); + + $gateway = new EfiGateway('client-id', 'client-secret', true, $client); + $gateway->getToken(); + $gateway->getToken(); + + expect($history)->toHaveCount(1); +})->group('efi'); + +it('autoriza de novo quando o token expira', function () { + $history = []; + $client = mockClient([ + /* 10s de vida é menos que a margem de 30s: já nasce vencido */ + jsonResponse(['access_token' => 'tok_1', 'token_type' => 'Bearer', 'expires_in' => 10]), + jsonResponse(['access_token' => 'tok_2', 'token_type' => 'Bearer', 'expires_in' => 600]), + ], $history); + + $gateway = new EfiGateway('client-id', 'client-secret', true, $client); + + expect($gateway->getToken()['access_token'])->toBe('tok_1') + ->and($gateway->getToken()['access_token'])->toBe('tok_2') + ->and($history)->toHaveCount(2); +})->group('efi'); + it('falha com ApiException quando a autorização não devolve access_token', function () { $client = mockClient([jsonResponse(['error' => 'invalid_client'])]); @@ -45,30 +73,24 @@ ->toBeInstanceOf(Charge::class); })->group('efi'); -it('declara apenas a capacidade de cobranças', function () { +it('declara as capacidades das duas APIs, menos clientes', function () { $gateway = new EfiGateway('id', 'secret', true, mockClient([])); - expect(Capability::of($gateway))->toBe([Capability::CHARGES]); + expect(Capability::of($gateway))->toBe([ + Capability::CHARGES, + Capability::WEBHOOKS, + Capability::PIX_KEYS, + Capability::SUBSCRIPTIONS, + ]); })->group('efi'); -it('avisa pela facade quais capacidades a efí oferece', function (Capability $capability) { +it('avisa pela facade que a efí não tem clientes', function () { $phpay = PHPay::gateway(new EfiGateway('id', 'secret', true, mockClient([]))); - expect($phpay->supports($capability))->toBeFalse(); - - expect(fn () => match ($capability) { - Capability::CUSTOMERS => $phpay->customer(), - Capability::WEBHOOKS => $phpay->webhook(), - Capability::PIX_KEYS => $phpay->pix(), - Capability::SUBSCRIPTIONS => $phpay->subscription(), - default => null, - })->toThrow(NotImplementedException::class, 'Capacidades disponíveis: cobranças.'); -})->with([ - Capability::CUSTOMERS, - Capability::WEBHOOKS, - Capability::PIX_KEYS, - Capability::SUBSCRIPTIONS, -])->group('efi'); + expect($phpay->supports(Capability::CUSTOMERS))->toBeFalse() + ->and(fn () => $phpay->customer()) + ->toThrow(NotImplementedException::class, 'Capacidades disponíveis: cobranças, webhooks, chaves Pix, assinaturas.'); +})->group('efi'); it('monta o payload de pessoa física na cobrança', function () { $history = []; diff --git a/tests/Unit/Efi/PixAuthorizationTest.php b/tests/Unit/Efi/PixAuthorizationTest.php new file mode 100644 index 0000000..f6b1f27 --- /dev/null +++ b/tests/Unit/Efi/PixAuthorizationTest.php @@ -0,0 +1,114 @@ +getValue($resource); + + return $client; +} + +beforeEach(function () { + $this->certificatePath = sys_get_temp_dir() . '/phpay-efi-' . bin2hex(random_bytes(6)) . '.p12'; + file_put_contents($this->certificatePath, 'bytes do p12'); +}); + +afterEach(function () { + if (is_file($this->certificatePath)) { + unlink($this->certificatePath); + } +}); + +it('pede o token da API Pix em oauth/token, não no v1/authorize da API de Cobranças', function () { + $history = []; + $gateway = efiPixGateway([], $history); + + expect($gateway->getPixToken()['access_token'])->toBe('pix_tok') + ->and((string) $history[0]['request']->getUri())->toBe('https://pix-h.api.efipay.com.br/oauth/token') + ->and(recordedBody($history))->toBe(['grant_type' => 'client_credentials']); +})->group('efi'); + +it('mantém o token da API Pix separado do token da API de Cobranças', function () { + $cobrancas = []; + $pix = []; + + $gateway = new EfiGateway( + 'id', + 'secret', + true, + mockClient([jsonResponse(['access_token' => 'cob_tok', 'token_type' => 'Bearer'])], $cobrancas), + pixClient: efiPixClient([efiPixToken('pix_tok')], $pix), + ); + + expect($gateway->getToken()['access_token'])->toBe('cob_tok') + ->and($gateway->getPixToken()['access_token'])->toBe('pix_tok') + ->and($cobrancas)->toHaveCount(1) + ->and($pix)->toHaveCount(1); +})->group('efi'); + +it('reaproveita o token da API Pix e renova quando expira', function () { + $history = []; + $gateway = new EfiGateway('id', 'secret', true, mockClient([]), pixClient: efiPixClient([ + efiPixToken('tok_1', 10), + efiPixToken('tok_2', 3600), + ], $history)); + + expect($gateway->getPixToken()['access_token'])->toBe('tok_1') + ->and($gateway->getPixToken()['access_token'])->toBe('tok_2') + ->and($gateway->getPixToken()['access_token'])->toBe('tok_2') + ->and($history)->toHaveCount(2); +})->group('efi'); + +it('falha com ApiException quando a autorização da API Pix não devolve access_token', function () { + $gateway = new EfiGateway('id', 'secret', true, mockClient([]), pixClient: efiPixClient([ + jsonResponse(['error' => 'invalid_client']), + ])); + + expect(fn () => $gateway->getPixToken())->toThrow(ApiException::class, 'API Pix'); +})->group('efi'); + +it('exige o certificado quando a biblioteca monta o próprio client', function () { + expect(fn () => (new EfiGateway('id', 'secret'))->pix()) + ->toThrow(ValidationException::class, 'exige o certificado'); +})->group('efi'); + +it('recusa no construtor um caminho de certificado inválido, sem chamada de rede', function () { + expect(fn () => new EfiGateway('id', 'secret', certificate: '/nao/existe/certificado.p12')) + ->toThrow(ValidationException::class, 'arquivo não encontrado'); +})->group('efi'); + +it('autoriza por mTLS e Basic no host Pix do ambiente', function (bool $sandbox, string $host) { + $client = builtClient(new PixAuthorization('id', 'secret', new Certificate($this->certificatePath, 'senha'), $sandbox)); + + expect((string) $client->getConfig('base_uri'))->toBe($host) + ->and($client->getConfig('auth'))->toBe(['id', 'secret']) + ->and($client->getConfig('cert'))->toBe([$this->certificatePath, 'senha']); +})->with([ + 'sandbox' => [true, 'https://pix-h.api.efipay.com.br/'], + 'produção' => [false, 'https://pix.api.efipay.com.br/'], +])->group('efi'); + +it('apresenta o certificado e o Bearer em toda requisição dos recursos Pix', function () { + $client = builtClient(new PixCharge( + ['access_token' => 'pix_tok', 'token_type' => 'Bearer'], + new Certificate($this->certificatePath), + )); + + expect($client->getConfig('cert'))->toBe($this->certificatePath) + ->and($client->getConfig('headers')['Authorization'])->toBe('Bearer pix_tok'); +})->group('efi'); diff --git a/tests/Unit/Efi/PixChargeTest.php b/tests/Unit/Efi/PixChargeTest.php new file mode 100644 index 0000000..5e42ab9 --- /dev/null +++ b/tests/Unit/Efi/PixChargeTest.php @@ -0,0 +1,198 @@ + $responses + * @param array $history + * @return PixCharge + */ +function pixCharge(array $responses, array &$history = []): PixCharge +{ + return new PixCharge(['access_token' => 'tok', 'token_type' => 'Bearer'], null, true, efiPixClient($responses, $history)); +} + +it('cria cobrança imediata com o valor em reais como string', function () { + $history = []; + + pixCharge([jsonResponse(['txid' => 'abc', 'loc' => ['id' => 7]])], $history) + ->setAmount(Money::reais('123,45')) + ->setKey('chave@phpay.io') + ->setCustomer(new Customer('Mário Lucas', '12345678909')) + ->setDescription('Pedido 1234') + ->setExpiration(1800) + ->setAdditionalInfo(['Pedido' => '1234', 'Loja' => 'Centro']) + ->create(); + + expect($history[0]['request']->getMethod())->toBe('POST') + ->and((string) $history[0]['request']->getUri())->toBe('https://pix-h.api.efipay.com.br/v2/cob') + ->and(recordedBody($history))->toBe([ + 'valor' => ['original' => '123.45'], + 'chave' => 'chave@phpay.io', + 'devedor' => ['nome' => 'Mário Lucas', 'cpf' => '12345678909'], + 'solicitacaoPagador' => 'Pedido 1234', + 'calendario' => ['expiracao' => 1800], + 'infoAdicionais' => [ + ['nome' => 'Pedido', 'valor' => '1234'], + ['nome' => 'Loja', 'valor' => 'Centro'], + ], + ]); +})->group('efi'); + +it('usa PUT com o txid informado', function () { + $history = []; + $txid = str_repeat('a1', 16); + + pixCharge([jsonResponse(['txid' => $txid])], $history) + ->setAmount(Money::centavos(1)) + ->setKey('chave') + ->create($txid); + + expect($history[0]['request']->getMethod())->toBe('PUT') + ->and((string) $history[0]['request']->getUri())->toEndWith("v2/cob/{$txid}") + ->and(recordedBody($history)['valor'])->toBe(['original' => '0.01']); +})->group('efi'); + +it('recusa txid fora do padrão do BACEN sem chamar a API', function () { + $history = []; + + expect(fn () => pixCharge([], $history)->setAmount(Money::reais(10))->setKey('chave')->create('curto')) + ->toThrow(ValidationException::class, '26 a 35 caracteres'); + + expect($history)->toBeEmpty(); +})->group('efi'); + +it('só aceita Money, porque a API Pix quer reais e a de Cobranças quer centavos', function () { + expect(fn () => pixCharge([])->setAmount(1000))->toThrow(TypeError::class); +})->group('efi'); + +it('exige valor e chave', function () { + expect(fn () => pixCharge([])->setKey('chave')->create()) + ->toThrow(ValidationException::class, 'setAmount') + ->and(fn () => pixCharge([])->setAmount(Money::reais(10))->create()) + ->toThrow(ValidationException::class, 'setKey'); +})->group('efi'); + +it('cria cobrança com vencimento, gerando o txid que a cobv exige', function () { + $history = []; + + pixCharge([jsonResponse(['txid' => 'x'])], $history) + ->setAmount(Money::reais(250)) + ->setKey('chave') + ->setCustomer(new Customer('Sixtec LTDA', '12345678000199')) + ->setDueDate('2026-12-31', 15) + ->create(); + + $uri = (string) $history[0]['request']->getUri(); + + expect($history[0]['request']->getMethod())->toBe('PUT') + ->and($uri)->toMatch('#/v2/cobv/[a-f0-9]{32}$#') + ->and(recordedBody($history)['calendario'])->toBe([ + 'dataDeVencimento' => '2026-12-31', + 'validadeAposVencimento' => 15, + ]) + ->and(recordedBody($history)['devedor'])->toBe(['nome' => 'Sixtec LTDA', 'cnpj' => '12345678000199']); +})->group('efi'); + +it('exige devedor e data válida na cobrança com vencimento', function () { + expect(fn () => pixCharge([])->setAmount(Money::reais(10))->setKey('chave')->setDueDate('2026-12-31')->create()) + ->toThrow(ValidationException::class, 'exige devedor') + ->and(fn () => pixCharge([]) + ->setAmount(Money::reais(10)) + ->setKey('chave') + ->setCustomer(['nome' => 'Mário', 'cpf' => '12345678909']) + ->setDueDate('2026-02-30') + ->create()) + ->toThrow(ValidationException::class, 'data válida'); +})->group('efi'); + +it('volta a ser imediata quando a expiração vem depois do vencimento', function () { + $history = []; + + pixCharge([jsonResponse([])], $history) + ->setAmount(Money::reais(10)) + ->setKey('chave') + ->setDueDate('2026-12-31') + ->setExpiration(600) + ->create(); + + expect((string) $history[0]['request']->getUri())->toEndWith('v2/cob') + ->and(recordedBody($history)['calendario'])->toBe(['expiracao' => 600]); +})->group('efi'); + +it('recusa devedor com cpf e cnpj ao mesmo tempo', function () { + expect(fn () => pixCharge([])->setCustomer(['nome' => 'X', 'cpf' => '12345678909', 'cnpj' => '12345678000199'])) + ->toThrow(ValidationException::class, 'nunca os dois'); +})->group('efi'); + +it('cancela pelo status do padrão BACEN, na cob e na cobv', function () { + $history = []; + $charge = pixCharge([jsonResponse([]), jsonResponse([])], $history); + + $charge->cancel('txid-cob'); + $charge->cancelDue('txid-cobv'); + + expect($history[0]['request']->getMethod())->toBe('PATCH') + ->and((string) $history[0]['request']->getUri())->toEndWith('v2/cob/txid-cob') + ->and(recordedBody($history, 0))->toBe(['status' => 'REMOVIDA_PELO_USUARIO_RECEBEDOR']) + ->and((string) $history[1]['request']->getUri())->toEndWith('v2/cobv/txid-cobv') + ->and(recordedBody($history, 1))->toBe(['status' => 'REMOVIDA_PELO_USUARIO_RECEBEDOR']); +})->group('efi'); + +it('lista com o período obrigatório dos últimos 30 dias por padrão', function () { + $history = []; + $charge = pixCharge([jsonResponse(['cobs' => []]), jsonResponse(['cobs' => []])], $history); + + $charge->getAll(); + $charge->setQueryParams(['inicio' => '2026-01-01T00:00:00Z', 'fim' => '2026-01-31T23:59:59Z', 'status' => 'ATIVA']) + ->getAllDue(); + + parse_str($history[0]['request']->getUri()->getQuery(), $padrao); + parse_str($history[1]['request']->getUri()->getQuery(), $filtrado); + + expect($padrao)->toHaveKeys(['inicio', 'fim']) + ->and($padrao['inicio'])->toMatch('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/') + ->and((string) $history[1]['request']->getUri()->getPath())->toBe('/v2/cobv') + ->and($filtrado)->toBe(['inicio' => '2026-01-01T00:00:00Z', 'fim' => '2026-01-31T23:59:59Z', 'status' => 'ATIVA']); +})->group('efi'); + +it('busca o QR Code pelo id do location', function () { + $history = []; + + $qr = pixCharge([jsonResponse(['qrcode' => '000201...', 'imagemQrcode' => 'data:image/png;base64,...'])], $history) + ->qrCode(7); + + expect($qr['qrcode'])->toBe('000201...') + ->and((string) $history[0]['request']->getUri())->toEndWith('v2/loc/7/qrcode'); +})->group('efi'); + +it('devolve um Pix recebido com o valor em reais', function () { + $history = []; + $charge = pixCharge([jsonResponse(['status' => 'EM_PROCESSAMENTO']), jsonResponse([])], $history); + + $charge->refund('E12345678202609211200abcdefghijk', Money::reais(10), 'D1'); + $charge->refund('E12345678202609211200abcdefghijk', Money::centavos(50)); + + expect($history[0]['request']->getMethod())->toBe('PUT') + ->and((string) $history[0]['request']->getUri())->toEndWith('v2/pix/E12345678202609211200abcdefghijk/devolucao/D1') + ->and(recordedBody($history, 0))->toBe(['valor' => '10.00']) + ->and((string) $history[1]['request']->getUri())->toMatch('#/devolucao/[a-f0-9]{32}$#') + ->and(recordedBody($history, 1))->toBe(['valor' => '0.50']); +})->group('efi'); + +it('chega à cobrança Pix pelo gateway, com o token da API Pix', function () { + $history = []; + + efiPixGateway([jsonResponse(['txid' => 'abc'])], $history) + ->pixCharge() + ->setAmount(Money::reais(1)) + ->setKey('chave') + ->create(); + + expect((string) $history[0]['request']->getUri())->toEndWith('oauth/token') + ->and((string) $history[1]['request']->getUri())->toEndWith('v2/cob'); +})->group('efi'); diff --git a/tests/Unit/Efi/PixTest.php b/tests/Unit/Efi/PixTest.php new file mode 100644 index 0000000..37c5e99 --- /dev/null +++ b/tests/Unit/Efi/PixTest.php @@ -0,0 +1,30 @@ + '345e4568-e89b-12d3-a456-006655440001']), + ], $history))->pix(); + + expect($pix)->toBeInstanceOf(Pix::class) + ->and($pix->createKey()['chave'])->toBe('345e4568-e89b-12d3-a456-006655440001') + ->and($history[1]['request']->getMethod())->toBe('POST') + ->and((string) $history[1]['request']->getUri())->toBe('https://pix-h.api.efipay.com.br/v2/gn/evp') + ->and((string) $history[1]['request']->getBody())->toBe(''); +})->group('efi'); + +it('lista e remove chaves aleatórias', function () { + $history = []; + $pix = efiPixGateway([ + jsonResponse(['chaves' => ['345e4568-e89b-12d3-a456-006655440001']]), + new GuzzleHttp\Psr7\Response(204), + ], $history)->pix(); + + expect($pix->getAll()['chaves'])->toHaveCount(1) + ->and($pix->destroy('345e4568-e89b-12d3-a456-006655440001'))->toBeTrue() + ->and($history[2]['request']->getMethod())->toBe('DELETE') + ->and((string) $history[2]['request']->getUri())->toEndWith('v2/gn/evp/345e4568-e89b-12d3-a456-006655440001'); +})->group('efi'); diff --git a/tests/Unit/Efi/SubscriptionTest.php b/tests/Unit/Efi/SubscriptionTest.php new file mode 100644 index 0000000..010e902 --- /dev/null +++ b/tests/Unit/Efi/SubscriptionTest.php @@ -0,0 +1,164 @@ + $responses + * @param array $history + * @return Subscription + */ +function pixAutomatico(array $responses, array &$history = []): Subscription +{ + return new Subscription(['access_token' => 'tok', 'token_type' => 'Bearer'], null, true, efiPixClient($responses, $history)); +} + +/** + * a recurrence with every required field. + * + * @param Subscription $subscription + * @return Subscription + */ +function recorrenciaValida(Subscription $subscription): Subscription +{ + $subscription + ->setCustomer(new Customer('Mário Lucas', '12345678909')) + ->setContract('CONTRATO-2026-001') + ->setDescription('Plano mensal') + ->setAmount(Money::reais('49,90')) + ->setPeriodicity(PeriodicityEnum::MONTHLY, '2026-10-01'); + + return $subscription; +} + +it('cria a recorrência pela facade no formato do BACEN', function () { + $history = []; + + recorrenciaValida(PHPay::gateway(efiPixGateway([jsonResponse(['idRec' => 'RR123'])], $history))->subscription()) + ->setLocation(42) + ->create(); + + expect($history[1]['request']->getMethod())->toBe('POST') + ->and((string) $history[1]['request']->getUri())->toBe('https://pix-h.api.efipay.com.br/v2/rec') + ->and(recordedBody($history, 1))->toBe([ + 'vinculo' => [ + 'devedor' => ['nome' => 'Mário Lucas', 'cpf' => '12345678909'], + 'contrato' => 'CONTRATO-2026-001', + 'objeto' => 'Plano mensal', + ], + 'valor' => ['valorRec' => '49.90'], + 'calendario' => ['dataInicial' => '2026-10-01', 'periodicidade' => 'MENSAL'], + 'loc' => 42, + 'politicaRetentativa' => 'NAO_PERMITE', + ]); +})->group('efi'); + +it('aceita valor variável com mínimo, data final, retentativas e ativação por cobrança', function () { + $history = []; + + pixAutomatico([jsonResponse(['idRec' => 'RR123'])], $history) + ->setCustomer(['nome' => 'Sixtec LTDA', 'cnpj' => '12345678000199']) + ->setContract('C-1') + ->setMinimumAmount(Money::reais(30)) + ->setPeriodicity(PeriodicityEnum::YEARLY, '2026-10-01', '2030-10-01') + ->allowRetries() + ->setActivationTxid('33beb661beda44a8928fef47dbeb2dc5') + ->create(); + + $body = recordedBody($history); + + expect($body['valor'])->toBe(['valorMinimoRecebedor' => '30.00']) + ->and($body['calendario'])->toBe(['dataInicial' => '2026-10-01', 'dataFinal' => '2030-10-01', 'periodicidade' => 'ANUAL']) + ->and($body['politicaRetentativa'])->toBe('PERMITE_3R_7D') + ->and($body['ativacao'])->toBe(['dadosJornada' => ['txid' => '33beb661beda44a8928fef47dbeb2dc5']]); +})->group('efi'); + +it('recusa recorrência incompleta sem chamar a API', function (Closure $monta, string $message) { + $history = []; + + expect(fn () => $monta(pixAutomatico([], $history))->create()) + ->toThrow(ValidationException::class, $message); + + expect($history)->toBeEmpty(); +})->with([ + 'sem contrato' => [fn (Subscription $s) => $s->setCustomer(new Customer('Mário', '12345678909')), 'setContract'], + 'sem devedor' => [fn (Subscription $s) => $s->setContract('C-1'), 'exige devedor'], + 'sem calendário' => [fn (Subscription $s) => $s->setContract('C-1')->setCustomer(new Customer('Mário', '12345678909')), 'setPeriodicity'], + 'sem valor' => [fn (Subscription $s) => $s->setContract('C-1') + ->setCustomer(new Customer('Mário', '12345678909')) + ->setPeriodicity(PeriodicityEnum::MONTHLY, '2026-10-01'), 'setMinimumAmount'], + 'contrato longo' => [fn (Subscription $s) => recorrenciaValida($s)->setContract(str_repeat('x', 36)), 'até 35'], +])->group('efi'); + +it('conta caracteres, não bytes, no limite de 35', function () { + $history = []; + + /* 35 caracteres com acento passam de 35 bytes */ + recorrenciaValida(pixAutomatico([jsonResponse([])], $history)) + ->setDescription(str_repeat('ç', 35)) + ->create(); + + expect($history)->toHaveCount(1); +})->group('efi'); + +it('cancela a recorrência pelo status do BACEN', function () { + $history = []; + + pixAutomatico([jsonResponse(['status' => 'CANCELADA'])], $history)->cancel('RR123'); + + expect($history[0]['request']->getMethod())->toBe('PATCH') + ->and((string) $history[0]['request']->getUri())->toEndWith('v2/rec/RR123') + ->and(recordedBody($history))->toBe(['status' => 'CANCELADA']); +})->group('efi'); + +it('cria o location da jornada de QR Code sem corpo', function () { + $history = []; + + expect(pixAutomatico([jsonResponse(['id' => 42])], $history)->createLocation()['id'])->toBe(42) + ->and((string) $history[0]['request']->getUri())->toEndWith('v2/locrec') + ->and((string) $history[0]['request']->getBody())->toBe(''); +})->group('efi'); + +it('cria a cobrança do ciclo com a conta recebedora', function () { + $history = []; + + pixAutomatico([jsonResponse(['txid' => 'abc'])], $history) + ->setReceiver('12345-6', AccountTypeEnum::CHECKING, '0001') + ->createCharge('RR123', Money::reais('49,90'), '2026-11-05', ['infoAdicional' => 'Plano mensal']); + + expect((string) $history[0]['request']->getUri())->toEndWith('v2/cobr') + ->and(recordedBody($history))->toBe([ + 'idRec' => 'RR123', + 'calendario' => ['dataDeVencimento' => '2026-11-05'], + 'valor' => ['original' => '49.90'], + 'ajusteDiaUtil' => true, + 'recebedor' => ['agencia' => '0001', 'conta' => '12345-6', 'tipoConta' => 'CORRENTE'], + 'infoAdicional' => 'Plano mensal', + ]); +})->group('efi'); + +it('exige a conta recebedora na cobrança do ciclo', function () { + $history = []; + + expect(fn () => pixAutomatico([], $history)->createCharge('RR123', Money::reais(10), '2026-11-05')) + ->toThrow(ValidationException::class, 'setReceiver'); + + expect($history)->toBeEmpty(); +})->group('efi'); + +it('consulta e cancela a cobrança do ciclo', function () { + $history = []; + $subscription = pixAutomatico([jsonResponse(['status' => 'ATIVA']), jsonResponse(['status' => 'CANCELADA'])], $history); + + $subscription->findCharge('txid123'); + $subscription->cancelCharge('txid123'); + + expect((string) $history[0]['request']->getUri())->toEndWith('v2/cobr/txid123') + ->and($history[1]['request']->getMethod())->toBe('PATCH') + ->and(recordedBody($history, 1))->toBe(['status' => 'CANCELADA']); +})->group('efi'); diff --git a/tests/Unit/Efi/WebhookTest.php b/tests/Unit/Efi/WebhookTest.php new file mode 100644 index 0000000..3105eb7 --- /dev/null +++ b/tests/Unit/Efi/WebhookTest.php @@ -0,0 +1,74 @@ + $responses + * @param array $history + * @param array $webhook + * @return Webhook + */ +function pixWebhook(array $responses, array &$history = [], array $webhook = []): Webhook +{ + return new Webhook(['access_token' => 'tok', 'token_type' => 'Bearer'], $webhook, null, true, efiPixClient($responses, $history)); +} + +it('configura o webhook pela chave Pix, via facade', function () { + $history = []; + + PHPay::gateway(efiPixGateway([jsonResponse(['webhookUrl' => 'https://loja.com/webhook'])], $history)) + ->webhook(['chave' => 'loja@phpay.io', 'webhookUrl' => 'https://loja.com/webhook']) + ->create(); + + $request = $history[1]['request']; + + expect($request->getMethod())->toBe('PUT') + ->and((string) $request->getUri())->toBe('https://pix-h.api.efipay.com.br/v2/webhook/loja%40phpay.io') + ->and(recordedBody($history, 1))->toBe(['webhookUrl' => 'https://loja.com/webhook']) + ->and($request->hasHeader('x-skip-mtls-checking'))->toBeFalse(); +})->group('efi'); + +it('pede para a Efí pular o mTLS no servidor quando solicitado', function () { + $history = []; + + pixWebhook([jsonResponse([])], $history) + ->skipMtlsChecking() + ->create(['chave' => 'chave', 'webhookUrl' => 'https://loja.com/webhook?hmac=xyz']); + + expect($history[0]['request']->getHeaderLine('x-skip-mtls-checking'))->toBe('true'); +})->group('efi'); + +it('recusa webhook sem chave ou fora de https, sem chamar a API', function (array $webhook, string $message) { + $history = []; + + expect(fn () => pixWebhook([], $history, $webhook)->create()) + ->toThrow(ValidationException::class, $message); + + expect($history)->toBeEmpty(); +})->with([ + 'sem chave' => [['webhookUrl' => 'https://loja.com/webhook'], 'por chave Pix'], + 'url ruim' => [['chave' => 'chave', 'webhookUrl' => 'não é url'], 'URL válida'], + 'sem https' => [['chave' => 'chave', 'webhookUrl' => 'http://loja.com/webhook'], 'https'], +])->group('efi'); + +it('consulta, lista e remove pela chave', function () { + $history = []; + $webhook = pixWebhook([ + jsonResponse(['webhookUrl' => 'https://loja.com/webhook']), + jsonResponse(['webhooks' => []]), + new Response(204), + ], $history); + + $webhook->find('+5511999998888'); + $webhook->getAll(); + + expect($webhook->destroy('+5511999998888'))->toBeTrue() + ->and((string) $history[0]['request']->getUri())->toEndWith('v2/webhook/%2B5511999998888') + ->and($history[1]['request']->getUri()->getQuery())->toContain('inicio=') + ->and($history[2]['request']->getMethod())->toBe('DELETE'); +})->group('efi'); diff --git a/tests/Unit/Http/CertificateTest.php b/tests/Unit/Http/CertificateTest.php new file mode 100644 index 0000000..36c5242 --- /dev/null +++ b/tests/Unit/Http/CertificateTest.php @@ -0,0 +1,78 @@ +format())->toBe($extension) + ->and((new Certificate($path))->path())->toBe($path); + + unlink($path); +})->with(['p12', 'pem'])->group('support'); + +it('entrega ao Guzzle o caminho, com a senha só quando houver', function () { + $path = certificateFile(); + + expect((new Certificate($path))->guzzleOptions())->toBe(['cert' => $path]) + ->and((new Certificate($path, 'segredo'))->guzzleOptions())->toBe(['cert' => [$path, 'segredo']]); + + unlink($path); +})->group('support'); + +it('recusa certificado que não existe', function () { + expect(fn () => new Certificate('/nao/existe/certificado.p12')) + ->toThrow(ValidationException::class, 'arquivo não encontrado'); +})->group('support'); + +it('recusa formato que o cURL não reconhece pela extensão', function () { + $path = certificateFile('pfx'); + + expect(fn () => new Certificate($path)) + ->toThrow(ValidationException::class, 'basta renomear'); + + unlink($path); +})->group('support'); + +it('monta o certificado a partir de base64, num arquivo privado', function () { + $certificate = Certificate::fromBase64(base64_encode('bytes do p12'), 'segredo'); + + expect($certificate->format())->toBe('p12') + ->and(file_get_contents($certificate->path()))->toBe('bytes do p12') + ->and(fileperms($certificate->path()) & 0777)->toBe(0600) + ->and($certificate->guzzleOptions()['cert'])->toBe([$certificate->path(), 'segredo']); +})->group('support'); + +it('recusa base64 inválido', function () { + expect(fn () => Certificate::fromBase64('isto não é base64!')) + ->toThrow(ValidationException::class, 'base64 válido'); +})->group('support'); + +it('não expõe a senha num dump', function () { + $path = certificateFile(); + + $dump = print_r(new Certificate($path, 'senha-super-secreta'), true); + + expect($dump)->not->toContain('senha-super-secreta') + ->and($dump)->toContain('********'); + + unlink($path); +})->group('support'); diff --git a/tests/Unit/Support/MoneyTest.php b/tests/Unit/Support/MoneyTest.php index 790d355..eb4cf18 100644 --- a/tests/Unit/Support/MoneyTest.php +++ b/tests/Unit/Support/MoneyTest.php @@ -98,3 +98,13 @@ expect($valor->toCentavos())->toBe(10050) ->and($valor->toReais())->toBe(100.50); })->group('support'); + +it('formata em decimal com ponto, como o padrão Pix do BACEN', function (int $centavos, string $decimal) { + expect(Money::centavos($centavos)->toDecimal())->toBe($decimal); +})->with([ + [12345, '123.45'], + [100, '1.00'], + [7, '0.07'], + [0, '0.00'], + [123456789, '1234567.89'], +])->group('support');