Skip to content

Releases

v2.8.0 — Inter, o primeiro banco

Choose a tag to compare

@mariolucasdev mariolucasdev released this 25 Oct 01:45
60160b4

Décimo gateway, e o primeiro de um banco.

Inter

A API Pix do Inter é a especificação do BACEN quase literal: mesmos
caminhos, mesmos schemas, mesma taxonomia de erro. Isso encolheu o trabalho — é
o mesmo terreno da API Pix da Efí.

use PHPay\Http\Certificate;
use PHPay\Inter\InterGateway;
use PHPay\Support\{Customer, Money};

$gateway = new InterGateway(
    CLIENT_ID,
    CLIENT_SECRET,
    certificate: Certificate::pair('/caminho/certificado.crt', '/caminho/chave.key'),
);

$cobranca = $gateway->charge()
    ->setKey(PIX_KEY)
    ->setAmount(Money::reais('110,00'))
    ->setCustomer(Customer::make('Fulano de Teste', '12345678901'))
    ->create();
Capacidade
SupportsCharges cobrança Pix, imediata (cob) e com vencimento (cobv)
SupportsWebhooks um por chave Pix, como a Efí
SupportsWebhookEvents leitura da notificação

charge() é a cobrança Pix — na Efí charge() é o boleto —, porque o
boleto do Inter é outra API (/cobranca/v3) que esta versão não fala.

A notificação diverge do padrão em dois pontos

E os dois falham em silêncio, por isso vale saber antes de integrar.

O corpo é um array na raiz, não o {"pix": [...]} que o BACEN manda e que a
Efí entrega:

[{ "endToEndId": "E876...", "txid": "9711...", "valor": "110.00", "horario": "..." }]

Um parser escrito pela especificação não acharia a chave pix e devolveria
zero eventos para todo pagamento de verdade, sem erro nenhum. O
webhookEvents() lê as duas formas.

devolucoes é um objeto, onde o padrão manda array. Pode ser erro de
documentação deles, então as duas leituras passam: lista continua lista, objeto
vira lista de um.

Correção, publicada depois: estas notas diziam que o Inter acrescenta
/pix à URL do webhook. Não acrescenta — a notificação chega na URL que
você cadastrou. A afirmação veio de uma leitura errada da expressão
{$request.body#/webhookUrl/pix} que o Redoc mostra na referência: o callback
se chama pix na especificação e o renderizador juntou o nome à expressão.
O sufixo /pix é quirk documentado do Itaú, não do Inter. O código nunca
esteve errado — a biblioteca manda a URL como você passou.

O certificado vem em dois arquivos

O Inter entrega um .crt e um .key, e a documentação dele manda juntar com
openssl pkcs12 -export antes de usar. Não precisa: o cURL aceita as
metades como vêm.

Certificate::pair($crt, $key, $senha);                    // de arquivo
Certificate::pairFromBase64($crtB64, $keyB64, $senha);    // de variável de ambiente

A opção "sem mudança no core" custava mais do que parecia: o .pfx que aquele
comando gera era recusado pelo próprio Certificate, porque o Guzzle não
reconhece essa extensão — então o usuário convertia e ainda precisava renomear.

Num par a senha vai na chave, nunca no certificado. O Guzzle transforma o
segundo elemento de cert em CURLOPT_SSLCERTPASSWD, e o .crt é público:
não tem o que decifrar. Verificado contra um servidor que exige certificado de
cliente, nos três caminhos do par e no .p12 de controle.

O .gitignore passou a cobrir *.crt, *.cer e *.key. Chave privada
commitada não se desfaz com um revert.

O token é emitido por escopo

Primeiro gateway assim. scope é obrigatório e não existe token que carregue
todas as permissões, então o gateway pede a união do que as capacidades usam —
pedir escopo que a aplicação não tem reprova a requisição inteira.

O cache não é otimização: gerar token é limitado a 5 chamadas por minuto,
e a documentação manda reaproveitar pela hora de validade. Pedir por requisição
levaria 429 na sexta.

O que o Inter não declara, e por quê

  • estorno existe na API, mas o schema exige valor enquanto a capacidade
    promete que valor ausente estorna integral, e a chamada é endereçada pelo
    endToEndId do pagamento — mesma razão da Efí, então fica em
    charge()->refund();
  • assinatura de webhook: a notificação vem por mTLS, que o certificado já
    cobre. Não existe esquema para verificar;
  • chaves Pix: a API não tem endpoint de DICT. A chave que endereça o webhook
    é uma que você já tem;
  • clientes e assinaturas não existem na API.

Três coisas que a referência descreve e o gateway não serve, conferidas na
lista de operações: gestão de chave Pix, /loc/{id}/qrcode — o copia-e-cola
volta na própria cobrança — e a listagem de webhooks. A taxonomia de erros
menciona um GET /webhook, mas aquela seção é o texto do BACEN copiado inteiro.

Compatibilidade

Só entraram métodos novos; nenhuma assinatura existente mudou. A tabela de
capacidades do README ganhou as colunas de estorno e leitura de webhook, que
faltavam desde que as duas entraram.

Nada do Inter foi exercitado contra o sandbox dele: a forma dos payloads veio da
referência, e a suíte não sai para a rede.


Documentação: Inter ·
Certificado e mTLS

v2.7.0 — quem usa a biblioteca passa a conseguir testar

Choose a tag to compare

@mariolucasdev mariolucasdev released this 24 Oct 19:56
0e6d2e8

Uma mudança só, e ela é para quem usa a biblioteca, não para quem a mantém.

Testar sem sair para a rede

A suíte do PHPay injeta Client em cada Resource, e os construtores aceitam ?Client $client justamente para isso. Mas quem consome a biblioteca não constrói Resource: recebe o gateway montado, sobretudo resolvendo por nome. E o registry não aceita Client.

Resultado até aqui: uma aplicação que usasse o PHPay não escrevia teste de controller, de listener de webhook nem de job sem falar com o gateway de verdade.

use PHPay\Http\ClientFactory;

ClientFactory::fake([
    new Response(200, [], json_encode(['id' => 'pay_001', 'status' => 'PENDING'])),
]);

/* o código da aplicação roda igual, sem saber que está em teste */
$pedido->pagar();

ClientFactory::recorded();   // o que ele mandou
ClientFactory::stopFaking();
Membro
fake($respostas) daqui em diante, todo client montado responde da fila
faking() se está em modo falso
recorded() as requisições que a aplicação fez
stopFaking() volta ao normal e limpa o histórico

Por que na fábrica

Aceitar Client no registry era o caminho óbvio, e o levantamento o descartou: quatro gateways têm mais de um client.

Gateway Clients
Efí Cobranças + API Pix
Cielo escritas + consultas
Rede transações + host de OAuth

Um client só falsearia metade. Na Rede seria pior: ela pede token antes de cada chamada, num host separado, então a outra metade iria para a rede na primeira linha do teste.

Como toda a biblioteca monta client em ClientFactory::make() — 14 pontos de chamada, sem nenhum new Client( fora dela, e com teste varrendo src/ que reprova quem furar —, falsear ali alcança todos de uma vez. E nenhuma assinatura mudou: nem a do registry, nem a dos construtores.

Três coisas para não se machucar

O retry continua instalado. Falsear a rede não desliga a política que impede cobrança dupla — teste que queira exercitá-la põe duas respostas na fila.

Só request e options são gravados. Em modo falso as respostas são as que o seu teste enfileirou, então repeti-las não diz nada. O que o teste não sabe de antemão é a requisição.

É estado estático, com o mesmo contrato do Mail::fake() do Laravel: opt-in, e stopFaking() restaura. Desligue no teardown:

afterEach(fn () => ClientFactory::stopFaking());

Client injetado no construtor não é afetado, porque não passa pela fábrica — é o que mantém a suíte da própria biblioteca funcionando.

A página Testando cobre o resto, e o de-para completo está no CHANGELOG.

v2.6.0 — contrato comum para os verificadores de assinatura

Choose a tag to compare

@mariolucasdev mariolucasdev released this 24 Oct 10:30
bd66e9c

Uma mudança só, e ela é para quem não sabe o gateway em tempo de escrita.

Nenhuma quebra. O caminho até aqui passou por um desenho que quebrava, e a seção final explica.

Os verificadores de assinatura ganharam contrato

Os cinco gateways que assinam concordam em exatamente quatro métodos — um segredo, o corpo cru, os headers e uma resposta — e discordam em todo o resto: token no header, HMAC do corpo, RSA contra chave publicada, segredo na query.

Esse pedaço comum é o que código genérico precisa, e até agora ele não conseguia alcançá-lo: webhookSignature() devolvia object, sem nada para tipar.

use PHPay\Webhooks\VerifiesSignature;

$phpay = PHPay::make('asaas', $config);   // não sei o gateway em tempo de escrita

$ok = $phpay->webhookSignature()          // VerifiesSignature, não object
    ->setSecret($segredo)
    ->setPayload($corpo)
    ->setHeaders($headers)
    ->verify();

Quem segura o gateway concreto nunca teve o problema — AsaasGatewayInterface::webhookSignature() já devolve o verificador concreto. Isto é para quem não pode segurá-lo, que é o caso que a v2.5.0 passou a atender com PHPay::make().

Os setters de esquema específico ficam fora do contrato, porque pertencem a um esquema cada: setDataId() no Mercado Pago, setQuery() e setSignatureKey() na AbacatePay, setPublicKeys() na Woovi. Código genérico pergunta por eles com method_exists().

Por que a capacidade continua devolvendo object

O desenho óbvio seria estreitar o retorno da capacidade SupportsWebhookSignature. Isso é compatível para quem consome e fatal para quem implementa — tipo de retorno em PHP é covariante:

Fatal error: Declaration of GatewayPrivadoDeTerceiro::webhookSignature(): object
must be compatible with SupportsWebhookSignature::webhookSignature(): VerifiesSignature

Gateway próprio implementando uma capacidade é cenário suportado pela biblioteca, então aquele caminho exigiria um major. Em vez disso, só a facade estreita, e como a capacidade não garante mais o contrato, a facade confere em runtime e recusa um verificador que não o cumpra:

Privado: O verificador de assinatura deste gateway não cumpre
PHPay\Webhooks\VerifiesSignature.

Nos nove gateways da biblioteca isso nunca dispara, e há teste varrendo todos para continuar assim.

O que fica aberto

Quem tipa a capacidade para injeção de dependência — o que a documentação de capacidades recomenda — ainda recebe object. Fechar essa ponta exige major, e está registrado no CLAUDE.md para não ser redescoberto.

O de-para completo está no CHANGELOG.

v2.5.0 — estorno, status e webhook com forma única, e o gateway podendo vir da config

Choose a tag to compare

@mariolucasdev mariolucasdev released this 23 Oct 21:22
1b3fb83

Cinco frentes entraram desde a v2.4.0, e quatro delas são a mesma ideia aplicada em lugares diferentes: o que divergia entre os nove gateways passa a ter forma única.

Nenhuma assinatura existente mudou de comportamento. Duas quebram em caso de borda, e estão descritas no fim.

Estorno virou capacidade

Seis gateways já estornavam, com três nomes para a mesma operação — refund, cancel, void —, três unidades e três sentidos para valor ausente. E nenhum era alcançável pela facade: quem segurava PHPay tinha que descer para o gateway concreto e saber de antemão como o método se chamava ali.

$phpay->refund($id);                       // devolve tudo
$phpay->refund($id, 2500);                 // parte, na unidade daquele gateway
$phpay->refund($id, Money::reais(25.50));  // o mesmo valor nos sete

Asaas e Woovi ganharam o método — os dois gateways completos não o tinham, embora a API dos dois exponha o endpoint. Cielo e Pagar.me ganharam refund() sobre o endpoint que já tinham; cancel() continua nas duas, porque ali um endpoint só anula e estorna.

Efí e AbacatePay ficam de fora, cada uma pelo seu motivo, e os motivos estão na documentação.

Status de pagamento numa forma só

A mesma cobrança paga voltava como RECEIVED, COMPLETED, approved, PAID, paid, CONCLUIDA ou '00', dependendo de quem respondeu.

use PHPay\Asaas\Enums\InvoiceStatusEnum;
use PHPay\Support\PaymentStatus;

if (InvoiceStatusEnum::of($cobranca['status']) === PaymentStatus::PAID) {
    // libera o pedido
}

Os enums concretos continuam existindo e acessíveis. Cinco gateways não tinham enum de status e ganharam o seu — a Efí ganhou dois, porque as APIs de Cobranças e Pix respondem em vocabulários diferentes com as mesmas credenciais.

Três estados merecem nota, porque conflatá-los erraria sobre dinheiro: AUTHORIZED não é PAID (Cielo, PagBank e Mercado Pago separam autorização de captura), PARTIALLY_PAID existe pelo underpaid do Pagar.me, e PAID não é estado final — estorno e chargeback ainda vêm depois.

O webhook, do outro lado

A v2.3.0 provou que a notificação veio do gateway. Esta lê o que ela diz:

foreach ($gateway->webhookEvents($corpo) as $evento) {
    if ($evento->type === EventType::PAID) {
        liberarPedido($evento->resourceId);
    }
}

Devolve lista, nunca um evento. A API Pix da Efí manda pix como array — vários pagamentos numa requisição — e o PagBank manda o pedido com todas as cobranças. Tratar como objeto único perderia pagamento em silêncio.

E quando a notificação não carrega estado — Mercado Pago manda só data.id, a Cielo manda três campos —, status é null e requiresLookup() diz que é preciso consultar:

if ($evento->requiresLookup()) {
    $cobranca = $phpay->charge()->find($evento->resourceId);
}

O gateway pode vir da configuração

Antes só era possível começar sabendo a classe e a assinatura do construtor.

$phpay = PHPay::make('asaas', ['token' => $token, 'sandbox' => true]);

PHPay::gateway() continua sendo o preferido quando você sabe o gateway em tempo de escrita: segurando o concreto, capacidade ausente é erro de análise e não de runtime.

Config errada falha na construção, nomeando a chave — inclusive chave desconhecida, porque um skandbox engolido é typo que só aparece em produção.

Guzzle 8, em comportamento e em tipos

A v2.5.0 aceita ^7.3 || ^8.0. A RetryPolicy continua distinguindo "não saiu da máquina" de "deu timeout depois de enviar" nas duas majors — a decisão que impede cobrança duplicada —, e agora o PHPStan nível 9 passa nas duas, com job próprio no CI.

Nada de nível afrouxado nem ignoreErrors: o que se passa ao Guzzle virou forma de array fechada, e a pilha de handlers é montada como variável local, porque declarar HandlerStack como tipo falha numa das duas majors com ou sem argumento genérico.

De passagem

Cobertura de testes de 88,8% para 91,2%, fechando os oito arquivos piores — entre eles o Asaas/Charge, que era o recurso mais usado da biblioteca e estava em 56,5%.

Duas quebras de borda

  1. O parâmetro do refund() do Mercado Pago foi renomeado de $value para $amount. O tipo alargou (?float → Money|int|float|null), então chamada posicional continua valendo e número cru continua lido em reais. Quebra só quem chamava por argumento nomeado: refund($id, value: 10.5).

  2. Três métodos protected saíram do HasRedeClient: clientRedeOauthBoot(), oauthUri() e oauthTokenPath(). Não tinham chamador em lugar nenhum — o RedeGateway sempre montou aquele client inline. É trait interno, mas quem o tivesse usado em código próprio sentiria.

O de-para completo está no CHANGELOG.

v2.4.0 — QR Code estático de Pix, CI no PHP 8.1 e a documentação em docs/

Choose a tag to compare

@mariolucasdev mariolucasdev released this 22 Oct 20:45
04a52c3

Quatro frentes de melhoria entraram desde a v2.3.0 — e a maior delas não é código, é a documentação ficar legível.

Nenhuma assinatura de método mudou. O que mudou de lugar foi a documentação.

QR Code estático de Pix, composto aqui

Pelo padrão do BACEN um QR Code estático é montado localmente, a partir da chave: não há o que pedir a ninguém. Asaas e Woovi expõem endpoint por conveniência, a Efí não expõe — e até agora a biblioteca só podia repassar essa assimetria para quem integrava.

use PHPay\Support\{Money, StaticPixCode};

StaticPixCode::make('fale@phpay.io', 'Sixtec LTDA', 'BRASILIA')
    ->withAmount(Money::reais('100,50'))   // opcional: sem valor, o pagador digita
    ->withTxid('PEDIDO1234')               // opcional: *** quando ausente
    ->payload();

É suporte, como o Money: não é Resource, não é capacidade, não entra na enum Capability — não há chamada HTTP. Devolve só o copia-e-cola; renderizar a imagem exigiria um codificador de QR Code, e dependência nova numa biblioteca de pagamentos custa mais que a conveniência.

O CRC é a parte que erra. Ele cobre o literal 6304 que o anuncia, não para antes dele — e parar antes gera um código que simplesmente não lê, erro que aparece na frente do pagador e não no log. Por isso o vetor de teste do Manual de Padrões para Iniciação do Pix está na suíte exigindo o payload byte a byte, e também no smoke test do PHP 8.1.

Dois detalhes que valem a leitura da documentação: o nome do recebedor é exigido pelo padrão e ignorado pelo app do pagador, que mostra o nome vindo do DICT; e o txid aceita só letras e números, então PEDIDO-1234 não passa.

O PHP 8.1 passa a ser verificado por execução

O composer.json promete ^8.1, mas o CI rodava a matriz a partir do 8.2 — a compatibilidade com a versão mínima era garantida só pela análise estática. Agora existe um job próprio no 8.1: valida o composer.json, instala apenas as dependências de runtime, confere a sintaxe de src/ e roda um smoke test que carrega o autoload, instancia os nove gateways, percorre as capacidades e exercita os value objects. A suíte do Pest continua fora dessa versão, porque o toolchain de dev exige 8.2+.

Junto: cobertura de testes medida no CI, num job só, com o número no log e no resumo.

A documentação saiu do README

O README tinha 1.338 linhas e 46 KB; quem chegava pelo Packagist rolava 500 linhas de conceito antes de achar o gateway que usa. Agora tem 308 linhas, com o essencial — o que é, a matriz de capacidades, requisitos, instalação, início rápido — e um índice.

O resto virou docs/: sete páginas de conceito e nove de gateway.

Atenção: link externo que apontava para uma âncora do README antigo deixa de funcionar. O índice de docs/ cobre todos os destinos.

Nesse trabalho apareceu um defeito que estava escondido: o bloco de código do exemplo de recorrência da Cielo nunca era fechado, e cercas de markdown alternam — o título da Rede e a prosa embaixo dele renderizavam como código no GitHub, e isso seguia por AbacatePay, Woovi e Efí. Dez links do sumário apontavam para âncoras que, por causa disso, não existiam. Corrigido, e a matriz de capacidades ganhou a coluna de assinatura de webhook, que entrou na v2.3.0 e tinha ficado fora da tabela.

Para não repetir: o gate agora tem composer test:links, que confere todo link relativo e toda âncora dos markdowns do repositório — e não conta título dentro de bloco de código, que foi justamente como o erro passou.

Também

  • O Dependabot deixa de propor major do Guzzle: a 8 removeu o acessor de que a RetryPolicy depende para não repetir um POST já processado. O motivo e o que falta estão registrados.
  • 506 testes (de 483), PHPStan nível 9, e CI obrigatório para mergear.

O detalhamento item por item está no CHANGELOG.

Instalação: composer require phpay-io/phpay

v2.3.0 — verificação de assinatura de webhook, prazos e credencial fora de dump

Choose a tag to compare

@mariolucasdev mariolucasdev released this 22 Oct 11:43
1056922

Cadastrar o webhook era metade do caminho. Esta versão entrega a outra: provar que a notificação veio do gateway. Junto vem a auditoria de segurança do bloco 1 — prazo em toda requisição, credencial fora de dump e unidade monetária validada.

Tudo aditivo: nenhuma assinatura de método mudou.

Antes de atualizar

  • ext-openssl passou a ser requisito de runtime, por causa da verificação RSA da Woovi. Habilite a extensão antes de atualizar.
  • Valor decimal na Efí, no PagBank e no Pagar.me agora lança ValidationException em vez de emitir uma cobrança com o valor errado. Se você mandava 10.50 onde o gateway espera 1050, a cobrança saía de onze centavos sem erro nenhum — agora para na validação, com a conversão na mensagem.

Verificação de assinatura de webhook

Capacidade nova, Capability::WEBHOOK_SIGNATURE, pelo mesmo caminho das outras:

$ok = $gateway->webhookSignature()
    ->setSecret($segredoDoWebhook)
    ->setPayload(file_get_contents('php://input'))
    ->setHeaders(getallheaders())
    ->verify();
Gateway O que é verificado O que isso prova
Asaas token no header asaas-access-token que o remetente conhece o token — o corpo não é assinado
PagBank x-authenticity-token: SHA-256 de {token}-{corpo cru} que o corpo chegou inteiro
Mercado Pago x-signature: HMAC-SHA256 de id:…;request-id:…;ts:…; que é da sua aplicação e aponta para aquele recurso
Woovi/OpenPix x-webhook-signature: RSA-SHA256 do corpo, contra as chaves publicadas que o corpo veio da Woovi
AbacatePay webhookSecret na query, mais o HMAC do corpo quando a chave é dada que a URL é a sua e, com a chave, que o corpo não mudou

O corpo verificado é sempre o cru (php://input, ou $request->getContent() no Laravel): reserializar o JSON muda o hash. verify() devolve bool, porque assinatura que não bate é resposta — o que levanta ValidationException é uso errado (segredo ausente, corpo vazio, header que não chegou).

Efí, Cielo, Rede e Pagar.me não declaram a capacidade, e isso é de propósito: a Efí notifica por mTLS, Cielo e Rede não mandam assinatura nenhuma e a v5 da Pagar.me não documenta esquema. Nos quatro, webhookSignature() levanta NotImplementedException. Um verificador sem nada para checar teria que responder true, o que é pior que não ter verificação, porque parece ter.

Segurança e robustez

  • Prazo em toda requisição: 60s no total, 10s para conectar, pela PHPay\Http\ClientFactory, que passa a ser o único lugar onde um client nasce. O padrão do Guzzle é esperar para sempre, e um gateway travado prendia o worker até o processo morrer.
  • Credencial fora de dump: PHPay\Http\MasksCredentials dá __debugInfo() a todo gateway e, pelo HasHttpClient, a todo recurso — a credencial vira ******** e o Client inteiro fica oculto, porque ele carrega o token nos headers. O dd() do Symfony VarDumper lê propriedade privada por conta própria e não é coberto.
  • #[\SensitiveParameter] em todo parâmetro que recebe credencial, para não aparecer em stack trace.
  • Money recusa NAN, INF e valor que estouraria os centavos num inteiro de 64 bits. Os três convertiam para 0 ou davam a volta e viravam negativo, passando por todas as checagens.

Também

  • CHANGELOG.md retroativo, cobrindo as cinco releases anteriores.
  • 440 testes (de 393), nenhum tocando a rede. PHPStan nível 9.

O detalhamento item por item está no CHANGELOG.

Instalação: composer require phpay-io/phpay

v2.2.0 — Efí Pix com mTLS e assinaturas completas do Asaas

Choose a tag to compare

@mariolucasdev mariolucasdev released this 21 Sep 15:41
459fca8

Duas lacunas fechadas. O Efí, que era o gateway mais fraco da biblioteca com 1 de 5 capacidades, passa a ter 4 de 5 com a API Pix. As assinaturas do Asaas, que só sabiam criar, passam a cobrir os 14 endpoints da API.

Para quem chama a biblioteca, tudo é aditivo. Veja a nota de compatibilidade abaixo.

🔐 Efí: API Pix com mTLS

O Efí tem duas APIs com as mesmas credenciais. A de Cobranças (boleto) já estava integrada, e a v2.2.0 integra a API Pix:

Gateway Clientes Cobranças Assinaturas Webhooks Chaves Pix
Efí v2.1.0 — ✅ — — —
Efí v2.2.0 — ✅ ✅ ✅ ✅

Toda requisição da API Pix é por mTLS, inclusive a do token. Basta passar o certificado da aplicação:

$efi = new EfiGateway($clientId, $clientSecret, certificate: '/caminho/certificado.p12');

$cobranca = $efi->pixCharge()
    ->setAmount(Money::reais('123,45'))
    ->setKey('sua-chave-pix')
    ->setCustomer(new Customer('Mário Lucas', '12345678909'))
    ->create();

$efi->pixCharge()->qrCode($cobranca['loc']['id'])['qrcode'];   // copia e cola
  • Cobrança Pix (pixCharge()): imediata ou com vencimento, QR Code e devolução
  • Chaves Pix (pix()): chaves aleatórias (EVP)
  • Webhooks (webhook()): um por chave Pix, com skipMtlsChecking() para servidores que não validam o certificado do Efí
  • Pix Automático (subscription()): o pagador autoriza uma vez no app do banco, e cada ciclo é debitado sem nova aprovação. Cobre a recorrência, o QR Code de autorização e a cobrança de cada ciclo

Na API Pix, o valor só entra como Money. Ela quer reais ("123.45"), enquanto a API de Cobranças do mesmo gateway quer centavos. Aceitar número cru ali recriaria exatamente o erro que o Money existe para evitar.

🪪 Certificate: mTLS genérico

O padrão do BACEN para API Pix exige mTLS, e Inter, BB, Itaú, Sicoob e Sicredi seguem o mesmo esquema. Por isso o certificado é PHPay\Http\Certificate, e não algo exclusivo do Efí:

new Certificate('/caminho/certificado.p12', 'senha');
Certificate::fromBase64(getenv('EFI_CERTIFICATE_BASE64'));   // para container e serverless

fromBase64() grava o certificado num temporário com permissão 0600, apagado ao fim do processo. A senha nunca aparece num var_dump().

🔁 Asaas: assinaturas completas

Antes só existia create(). Agora o recurso cobre toda a API de assinaturas do Asaas:

$assinaturas = $phpay->subscription();

$assinatura = $assinaturas
    ->setCustomer($cliente)
    ->setAmount(Money::reais('49,90'))
    ->setCycle(SubscriptionCycleEnum::MONTHLY)
    ->setSubscription(['billingType' => 'BOLETO', 'nextDueDate' => '2026-10-10'])
    ->create();

$id = $assinatura['id'];

$assinaturas->deactivate($id);                 // pausa sem apagar cobranças
$assinaturas->reactivate($id, '2026-11-10');   // o Asaas exige novo vencimento
file_put_contents('carne.pdf', $assinaturas->paymentBook($id));
  • getAll(), find(), update(), destroy(), deactivate() e reactivate()
  • getPayments(): as cobranças já geradas pela assinatura
  • paymentBook(): o carnê, como os bytes do PDF
  • updateCreditCard(): troca o cartão sem cobrar
  • Nota fiscal emitida automaticamente a cada cobrança: create/get/update/destroyInvoiceSettings() e getInvoices()

🐛 Correções

  • Efí: o token da API de Cobranças ficava em cache para sempre. Um gateway que fica vivo num worker de fila passava a tomar 401 quando o Efí expirava o token. Agora o token é renovado sozinho, e o da API Pix também.
  • Asaas: cycle passa a ser obrigatório na criação de assinatura, como na especificação do Asaas. Antes o payload sem ele passava pela validação e só falhava no gateway.

⚠️ Nota de compatibilidade

Para quem chama a biblioteca, nada muda. Os parâmetros novos do EfiGateway (certificate e pixClient) são opcionais e ficam no fim do construtor, e create([...]) da assinatura do Asaas funciona como antes.

Três pontos para conferir:

  1. O Guzzle mínimo sobe de ^7.0 para ^7.3, a primeira versão que sabe usar certificado .p12. A 7.3 é de março de 2021.
  2. Criar assinatura no Asaas sem cycle agora lança ValidationException antes da chamada, em vez de ApiException do Asaas. A requisição já falhava; só falha mais cedo e com mensagem melhor.
  3. Quem implementa a SubscriptionInterface do Asaas fora da biblioteca precisa acompanhar as assinaturas novas: create(array $subscription = []) ganhou default, e a interface ganhou métodos. Quem só usa o recurso não é afetado.

🧪 Qualidade

275 → 354 testes (968 asserções), todos com HTTP simulado e sem rede.

  • Os testes do Efí conferem que o client montado apresenta o certificado e o token em toda requisição, no host certo de cada ambiente.
  • As rotas foram conferidas no SDK oficial do Efí, no padrão Pix do BACEN e na especificação oficial do Asaas.

Ainda não foram exercitadas contra os sandboxes reais, o que exige o certificado de homologação do Efí e o token de sandbox do Asaas. Os scripts examples/efi/pix.php e examples/asaas/subscriptions.php estão prontos para isso.

Full Changelog: v2.1.0...v2.2.0

v2.1.0 — Money, Customer e Woovi/OpenPix

Choose a tag to compare

@mariolucasdev mariolucasdev released this 21 Sep 14:41
6c9277f

Um gateway novo e dois value objects que fecham uma lacuna da v2.0.0: a biblioteca normalizava os nomes dos métodos entre gateways, mas deixava os payloads crus. Quem integrava ainda precisava saber que um gateway quer reais e outro quer centavos, e que o CPF se chama cpfCnpj num e document noutro.

Tudo aditivo — veja a nota de compatibilidade abaixo.

💸 Money: o erro de centavos vs reais, eliminado no tipo

Dois gateways usam reais decimais; os outros sete usam centavos inteiros. Mandar 100.50 num gateway de centavos não quebra a integração — cobra R$ 1,00, e você só descobre na conciliação.

use PHPay\Support\Money;

$valor = Money::reais(100.50);     // ou Money::centavos(10050)

$asaas->charge()->setAmount($valor);          // vira 100.50
$pagbank->charge()->addItem('Item', $valor);  // vira 10050

O mesmo objeto, a unidade certa em cada gateway.

  • Aceita string em notação brasileira: '100,50', '1.234,56'
  • multiply(), plus(), equals(), format() → 'R$ 100,50'
  • Recusa mais de duas casas decimais em vez de arredondar calado: Money::reais(10 / 3) lança exceção. Arredondamento silencioso é como nascem erros de um centavo na conciliação.
  • Novo setAmount() no Asaas, Mercado Pago e Efí, onde o valor vivia dentro do array de payload

👤 Customer: uma forma só para o cliente

O mesmo campo tinha seis grafias: cpfCnpj, tax_id, document, taxId, taxID, cpf_cnpj.

use PHPay\Support\Customer;

$cliente = Customer::make(
    name: 'Mário Lucas',
    document: '123.456.789-01',     // pontuação é limpa
    email: 'fale@phpay.io',
    phone: '(11) 94002-8922',
);

$phpay->charge()->setCustomer($cliente);   // funciona em todos

E carrega o que cada gateway deriva do cliente:

$cliente->isIndividual();   // o Pagar.me precisa como type
$cliente->documentType();   // 'CPF' | 'CNPJ', a Cielo quer em IdentityType
$cliente->firstName();      // o Mercado Pago quer o nome separado
$cliente->phoneParts();     // o PagBank quer {country, area, number}

O value object cobre todos os campos obrigatórios de cliente dos oito gateways que têm cliente. withExtra() fica para opcionais específicos, como endereço.

🎉 Gateway novo: Woovi/OpenPix

O segundo gateway com as cinco capacidades, ao lado do Asaas.

Gateway Clientes Cobranças Assinaturas Webhooks Chaves Pix
Asaas ✅ ✅ ✅ ✅ ✅
Woovi/OpenPix ✅ ✅ ✅ ✅ ✅

Duas empresas independentes preenchendo o mesmo contrato é evidência de que o modelo de capacidades da v2.0.0 descreve o domínio, e não um fornecedor.

$phpay->pix()->createKey(PixKeyTypeEnum::RANDOM);
$phpay->pix()->verifyKey('fale@phpay.io');
$phpay->charge()->setCorrelationId('pedido-1')->create(Money::reais(100.50));
$phpay->charge()->find('pedido-1');   // pelo SEU id, não pelo do gateway

⚠️ Nota de compatibilidade

Para quem chama a biblioteca, nada muda. Todo método que recebe valor aceita Money|int (ou Money|int|float nos gateways de reais), e todo setCustomer() aceita Customer|array. Número cru e array continuam funcionando, lidos como sempre foram.

Para quem implementou SupportsCustomers num gateway próprio, há uma mudança de assinatura:

// v2.0.0
public function customer(array $customer = []): object;

// v2.1.0
public function customer(Customer|array $customer = []): object;

Uma implementação com customer(array ...) passa a dar erro fatal no autoload, porque não pode aceitar menos que a interface. Ajuste a assinatura da sua implementação para Customer|array.

Pela regra estrita do SemVer isso seria major. Optamos por minor porque o contrato foi publicado na v2.0.0 horas antes, e a falha é alta — erro fatal imediato — e não silenciosa.

🧪 Qualidade

219 → 275 testes (759 asserções). Os mais importantes são os cruzados: o mesmo Money e o mesmo Customer produzindo o formato certo em cada gateway, numa expectativa só.

v2.0.0 — oito gateways, exceções tipadas e capacidades declaradas

Choose a tag to compare

@mariolucasdev mariolucasdev released this 21 Sep 05:34
117fd83

Primeira versão com quebras de compatibilidade desde o lançamento, e a maior da biblioteca até aqui: de 2 para 8 gateways, falhas passam a ser exceção, e o projeto ganha CI, guia de migração e licença nova.

O de-para completo, quebra por quebra, está em UPGRADE.md.

🎉 Seis gateways novos

Gateway Clientes Cobranças Assinaturas Webhooks Chaves Pix
Asaas ✅ ✅ ✅ ✅ ✅
Mercado Pago ✅ ✅ ✅ — —
PagBank ✅ ✅ ✅ — —
Pagar.me ✅ ✅ ✅ — —
AbacatePay ✅ ✅ — — —
Cielo — ✅ ✅ — —
Rede — ✅ — — —
Efí — ✅ — — —

Cobre o topo do mercado brasileiro: Cielo e Rede lideram entre adquirentes (28% por usuários e 18% por volume), Mercado Pago e PagBank dominam e-commerce e PME, Pagar.me é o favorito técnico, e o AbacatePay traz o perfil Pix-nativo.

🧩 Gateways declaram o que suportam

GatewayInterface era a união de tudo que o Asaas sabe fazer — todo gateway precisava declarar cinco recursos mesmo sem suportá-los. Agora cada recurso é uma interface que o gateway implementa se, e só se, oferecer:

use PHPay\Contracts\Capability;

$phpay->supports(Capability::SUBSCRIPTIONS);
$phpay->capabilities();
$phpay->name();

Chamar um recurso indisponível diz o que o gateway oferece:

NotImplementedException: Rede não suporta chaves Pix.
                         Capacidades disponíveis: cobranças.

E quem segura o gateway concreto ganha a checagem em tempo de análise — (new RedeGateway(...))->pix() vira erro de PHPStan, não de produção.

SupportsPixKeys significa gerenciar chaves e QR Code estático, o que só um PSP que emite chave própria oferece. Todos os oito aceitam Pix — a coluna vazia não quer dizer o contrário.

🚨 Erros viram exceção

Um array retornado agora é sempre sucesso. Todas as exceções implementam PHPayException:

Exceção Quando
ValidationException payload inválido, antes de qualquer HTTP
ApiException gateway recusou ou está inacessível
NotImplementedException recurso não suportado pelo gateway

ApiException carrega getStatusCode(), getResponse(), getGateway() e isConnectionError().

💸 Unidade monetária documentada

Não estava em lugar nenhum, e errar não quebra a integração — ela cobra o valor errado:

Gateway Unidade R$ 100,50
Asaas, Mercado Pago Reais (decimal) 100.50
PagBank, Pagar.me, Cielo, Rede, AbacatePay, Efí Centavos (inteiro) 10050

Nos que usam centavos, o PHPay recusa valor decimal na validação, antes da chamada.

🐛 Correções

  • A validação de tipo não funcionava. AsaasChargeRequest usava && onde deveria usar ||: um campo presente com o tipo errado passava batido e seguia para a API.
  • setCustomer() criava cliente duplicado a cada chamada. Agora reaproveita quando o array traz id, e existe setCustomerId().
  • EfiGateway fazia chamada de rede no construtor. Autoriza sob demanda, uma vez só.
  • Efi\Charge::getStatus() apontava para o endpoint do Asaas.
  • WebhookEventsEnum usava namespace fora do autoload e não carregava.
  • EfiGateway::customer()/webhook() devolviam stdClass vazio, causando "undefined method" adiante.
  • examples/efi/charges.php passava 100.00 num gateway que usa centavos — cobraria R$ 1,00.

🧪 Qualidade

  • 3 → 219 testes (589 asserções). Todo recurso aceita um client HTTP injetado; nenhum teste toca a rede.
  • CI no GitHub Actions, matriz 8.2/8.3/8.4 com dependências locked, lowest e highest.
  • PHPStan nível 9 como fonte única, com phpVersion mínimo 8.1 garantindo a compatibilidade declarada.
  • Checagem de conformidade contra sandbox para Mercado Pago e PagBank, que roda de verdade e recusa credenciais de produção.

⚖️ Licença: MIT → Business Source License 1.1

A partir desta versão, source-available com uma única restrição comercial: ninguém pode oferecer o PHPay, ou um derivado, como biblioteca/SDK/serviço de integração de pagamentos concorrente.

Usar em produção, inclusive em software fechado e comercial, continua liberado — quem integra pagamentos no próprio produto não é afetado.

Em 2030-09-20 converte automaticamente para MIT, e cada versão converte no máximo quatro anos após ser publicada.

As versões 1.0.0 e 1.0.1 permanecem sob MIT — licença nova não é retroativa, e quem já baixou mantém aqueles direitos para sempre.

Contribuições passam a estar sujeitas ao CLA.

⚠️ Breaking changes

  1. Métodos lançam exceção em vez de retornar ['error' => ...]
  2. Gateways declaram capacidades — GatewayInterface tem só name()
  3. PHPay\Gateways\Asaas\* e Efi\* deixaram de existir
  4. pix() não aceita mais argumento
  5. messages() de cobrança e assinatura ficou plano
  6. AsaasExceptions e AsaasPixRequest removidas
  7. AsaasGateway perdeu as propriedades públicas $client e $customer

Requisitos

Consumir a lib: PHP ^8.1 (inalterado). Desenvolver: PHP ^8.2.

v1.0.1

Choose a tag to compare

@mariolucasdev mariolucasdev released this 08 May 18:02
4d05f43

What's Changed

Full Changelog: v1.0.0...v1.0.1