Orientações para o Claude Code trabalhar neste repositório.
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 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.3 (a 7.3 é a primeira que entrega .p12 ao cURL pela
extensão, e o mTLS depende disso). Publicado no Packagist.
PHPay (facade) ──implements──▶ PHPay\Contracts\GatewayInterface
│ delega tudo para o gateway injetado no construtor
▼
AsaasGateway / EfiGateway ──implements──▶ <Gateway>Interface extends GatewayInterface
│ 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/patch/delete/download)
▼
Requests (validação estática dos payloads antes de qualquer chamada HTTP)
Regras que valem para todo código novo:
- Capacidade, não contrato único.
GatewayInterfacecarrega sóname(). Cada recurso é uma interface emsrc/Contracts/(SupportsCustomers,SupportsCharges,SupportsWebhooks,SupportsPixKeys,SupportsSubscriptions) que o gateway implementa se — e só se — oferecer aquele recurso. Nunca declare um recurso para depois lançar de dentro dele. - Recurso novo = interface nova + case na enum
Capability+ método guardado na facade. O guard éinstanceofseguido deNotImplementedException::forCapability(); o PHPStan usa esseinstanceofpara estreitar o tipo, então não troque por um helper genérico. - A facade
PHPaynão conhece gateway concreto, por isso ela checa em runtime. Quem segura o gateway concreto ganha a checagem em tempo de análise. - Cada Resource tem uma Interface própria em
Resources/<Nome>/Interface/. - Resource é descartável e carrega estado via setters fluentes (
setCharge,setCustomer,setCustomerId,setQueryParams,setFilter), sempre comreturn $thistipado pela interface, e um método terminal (create,getAll,find, …). - Todo Resource aceita
?Client $client = nullcomo último parâmetro do construtor e faz$this->client = $client ?? $this->client<Gateway>Boot(). É isso que torna a suíte testável sem rede — não crie Resource sem esse parâmetro. - Validação de payload vive em classes
*Requestestáticas, com o parvalidate(array): void+messages(): object. As mensagens são em português e não carregam o prefixo do gateway: quem prefixa éValidationException::make('<Gateway>', $mensagem). messages()precisa de object shape no PHPDoc (@return object{campo: string, ...}), senão o PHPStan nível 9 acusaproperty.notFound.- O trait do gateway expõe
baseUri()(sandbox/produção) egatewayName(). As URLs são métodos, não constantes: constante em trait só existe a partir do PHP 8.2 e a lib suporta 8.1 — ophpVersiondophpstan.neonreprova isso.
Regra central: um array retornado é sempre resposta de sucesso. Qualquer falha vira
exceção, e todas implementam PHPay\Exceptions\PHPayException:
| Exceção | Estende | Quando |
|---|---|---|
ValidationException |
InvalidArgumentException |
payload inválido, antes de qualquer HTTP |
ApiException |
RuntimeException |
gateway recusou ou está inacessível |
NotImplementedException |
BadMethodCallException |
recurso não suportado pelo gateway |
ApiException carrega getStatusCode(), getResponse(), getGateway() e
isConnectionError() (status 0 = nem chegou ao gateway). Construa sempre via
ApiException::fromThrowable(), que já resume os formatos de erro do Asaas
(errors[].description) e da Efí (error_description).
Rodar direto no host (requer PHP 8.2+ e Composer):
composer install
composer test # lint + unit + types — mesmo gate do CI
composer test:lint # pint --test (PSR-12 + regras do pint.json)
composer test:unit # pest
composer test:types # phpstan (nível e phpVersion vêm do phpstan.neon)
composer lint # pint -v (corrige)Ou via Docker (make help lista tudo):
make start && make install && make testRodar um subconjunto:
./vendor/bin/pest --filter="reaproveita o cliente"
./vendor/bin/pest --group=asaas # grupos: phpay, asaas, efiExemplos manuais contra o sandbox (examples/): copie
examples/<gateway>/credentials.example.php para credentials.php, preencha as constantes
e rode php examples/asaas/charges.php (ou make asaas resource=charges).
credentials.php não é versionado — nunca commitar token real.
- Nenhum teste toca a rede.
tests/Pest.phpexpõemockClient(array $responses, array &$history),jsonResponse(array $data, int $status)erecordedBody(array $history, int $index). - O padrão é: montar o Resource com o client mockado, executar, e asseverar sobre o histórico de requisições (método, URI, corpo) além do retorno.
- Todo teste declara um grupo:
->group('phpay' | 'asaas' | 'efi'). - O PHPStan analisa só
src— o Pest usa__callparagroup()/with()e o nível 9 não consegue resolver isso emtests.
- Estilo: PSR-12 via Pint, com alinhamento de
=e=>(align_single_space_minimal). Rodecomposer lintantes de commitar; o hookpre-commitrodapint --test+ phpstan. - PHPDoc em todo método, incluindo
@param array<mixed>/@return array<mixed>— o PHPStan roda em nível 9 e arrays sem generics quebram o build. Documente também o@throws. - Nada de
(string) $valorMixed. Nível 9 reprova cast demixed; useis_string()antes ou lance exceção. - Mensagens de commit: o hook
commit-msgexige que a branch contenha um número de issue e prefixa automaticamentePHPAY-<id>:. Formato do corpo:tipo(escopo): descrição(feat,fix,refactor,doc,wip,test). Branch base de PR:develop. Nunca adicione linhas de co-autoria ou atribuição em commits e PRs. - Hooks Husky:
pre-commit(pint + phpstan),pre-push(pest),commit-msg. Instalados pornpm install. - Namespaces:
PHPay\→src/, e um root por gateway:PHPay\Asaas\,PHPay\Efi\,PHPay\MercadoPago\→src/Gateways/<Gateway>/. Gateway novo precisa de um root novo nocomposer.json— não introduzaPHPay\Gateways\....
Use PHPay\Support\Customer. O mesmo campo tem seis grafias entre os gateways
(cpfCnpj, tax_id, document, taxId, taxID, cpf_cnpj), e o VO é a forma única.
Customer::make()limpa pontuação de documento e telefone; o construtor não.- O mapeamento mora na classe
*CustomerRequestde cada gateway, emfromCustomer(Customer): array— ela já detém o conhecimento do schema daquele gateway, então validação e mapeamento ficam juntos. Gateway novo com cliente deve ter o mesmo método. - Todo
setCustomer()ecustomer()aceitaCustomer|array. Array continua funcionando; não remova esse caminho sem major. - Lógica derivada mora no VO, não nos gateways:
isIndividual(),documentType(),firstName()/lastName(),phoneParts(). Se um gateway novo precisar de outra derivação, acrescente lá em vez de calcular no mapper. withExtra()é para campo específico de um gateway. Nenhum campo obrigatório precisa dele hoje — se um gateway novo precisar, é sinal de que o VO está faltando algo.
Use PHPay\Support\Money. Os gateways discordam da unidade — Asaas e Mercado
Pago querem reais decimais, os outros sete querem centavos inteiros — e errar não
quebra a integração, cobra o valor errado.
- Fábricas:
Money::reais()(aceita float, int e string em notação brasileira) eMoney::centavos(). Acessores na instância:toReais()etoCentavos(). - Dentro dos gateways, normalize com
Money::asReais()ouMoney::asCentavos(), que aceitamMoneyou número cru. Nunca leia o valor direto do parâmetro. - Todo método que recebe valor aceita
Money|int(ouMoney|int|floatnos de reais). Número cru é lido na unidade que aquele gateway sempre esperou — compatibilidade. - Onde o valor vive dentro do array de payload (Asaas, Mercado Pago, Efí), existe
setAmount(). Gateway novo com valor em array deve ter o mesmo. Money::reais()recusa mais de duas casas decimais de propósito. Não "conserte" isso com arredondamento: é o que impede erro de um centavo na conciliação.
- Asaas —
$sandboxtroca a base URL. Chaves Pix próprias, porque é PSP (como Woovi e Efí). Assinatura cobre os 14 endpoints da API. O carnê responde PDF, por isso usaHasHttpClient::download(), que devolve o corpo cru —request()decodifica JSON e devolveria[].destroy()leva as cobranças pendentes e vencidas; a pausa édeactivate(), e reativar exige um novonextDueDate.cycleé obrigatório na criação. Atualização de assinatura éPUT, como o resto do Asaas. - Efí — duas APIs com as mesmas credenciais: Cobranças (
cobrancas.api..., traitHasEfiClient, boleto emcharge()) e Pix (pix.api..., traitHasEfiPixClient, só por mTLS). Cada API tem o seu token (getToken()egetPixToken()), 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ó comoMoney(reais em string,toDecimal()), porque a API de Cobranças do mesmo gateway usa centavos — não abraMoney|intali. O certificado só é exigido quando o recurso monta o próprio client, por isso os testes injetampixCliente 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 emapi.assinaturas.pagseguro.com. O trait expõeclientPagBankBoot()eclientPagBankSubscriptionsBoot(); cada recurso boota o seu. Todo valor é inteiro em centavos — os validadores recusam decimal, porque mandar10.50onde se espera1050cobra onze centavos. Pix éqr_codesdo pedido (um só por pedido, copia-e-cola emqr_codes[0].text), não umacharge. - Woovi/OpenPix — segundo gateway com as cinco capacidades, junto com o Asaas.
AppID vai cru no
Authorization, sem esquema. Sandbox tem domínio próprio (api.woovi-sandbox.com). O webhook fica emapi/openpix/v1/enquanto os demais recursos ficam emapi/v1/— herança da fusão das marcas, não erro. Todo objeto é endereçável pelocorrelationID(id do sistema de quem integra), entãofind()edestroy()aceitam os dois ids. Valores em centavos. - AbacatePay — host único e sem prefixo de chave: não dá para derivar o
ambiente da credencial, então não existe
isSandbox()— inventar convenção aqui seria mentira. A resposta da cobrança trazdevMode, e é isso queisDevMode()lê. Cobrança é montada por produtos, não por valor; preço em centavos com mínimo de 100.frequencysó aceitaONE_TIME, por isso sem assinaturas. Cupons são extra do gateway concreto, como owebhookDeliveries()do Pagar.me. - Rede — host de OAuth separado do host de API, e o caminho do token muda por
ambiente (
oauth2/tokenvsredelabs/oauth2/token) — está emRedeEnvironment, fora do trait, para o gateway ler sem puxar os verbos HTTP. O token expira: é o único gateway com ciclo de vida de credencial, tratado emResources/Authorizationcom margem de 30s antes do vencimento. OChargepede um token a cada chamada e injeta como Bearer por requisição, em vez de fixar no header do client. - Cielo — dois hosts separados por tipo de operação, não por domínio: escritas
em
api.cieloecommerce..., consultas emapiquery.cieloecommerce.... O mesmo recurso usa os dois, por issoHasHttpClient::request()aceita um client opcional e o trait expõequeryGet(). Autenticação por headersMerchantId/MerchantKey. Valores em centavos. Recorrência não tem endpoint de criação: nasce de uma venda com blocoRecurrentPayment. Os endpoints de update da recorrência recebem um valor JSON puro no corpo (19900,"Monthly"), não um objeto — daí oputValue(). - Pagar.me — autenticação Basic (secret key como usuário, senha vazia), não
Bearer. Ambiente pelo prefixo
sk_test_, host único, então sem$sandbox. Valores em centavos. Cancelamento éDELETE /charges/{id}com valor opcional no corpo — userequest('DELETE', ...), porquedelete()do trait não manda corpo.webhookDeliveries()é extra do gateway concreto, não capacidade:/hookslê entregas, não cadastra endpoints. - Mercado Pago — não tem URL de sandbox: o ambiente vem do prefixo
TEST-do access token, então o construtor não recebe$sandbox.POST /v1/paymentsexigeX-Idempotency-Key(por issoHasHttpClient::post()aceita headers por requisição). Cliente não é pré-requisito de cobrança, e a API não oferece exclusão de cliente.
- 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. SupportsWebhookseSupportsPixKeyssó 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/hooksdo Pagar.me lê entregas, é outra coisa, e por isso virou um recurso fora do modelo.Efi\Resources\Charge\Chargetem$itemse$configurationprivados sem setter — hoje sempre caem no fallback (getItems()monta um item a partir dedescription/value;getConfigurations()usa fine 200 / interest 33).- O CI roda a matriz em 8.2/8.3/8.4; a compatibilidade com 8.1 é garantida
estaticamente pelo
phpVersion: min: 80100dophpstan.neon, não por execução real.
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 doClientque o trait monta; não passeCURLOPT_SSLCERTTYPEemcurl, porque o Guzzle recente recusa opção cURL que conflita com a dele, e ele já deduzP12pela extensão.- Só
.p12e.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.
Biblioteca de pagamentos: nunca logar, imprimir ou commitar tokens, access_token,
clientSecret ou CPF/CNPJ reais. $sandbox = true é o default em todos os construtores —
mantenha assim.