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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
53 changes: 41 additions & 12 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -24,7 +26,7 @@ AsaasGateway / EfiGateway ──implements──▶ <Gateway>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)
```
Expand Down Expand Up @@ -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 é
Expand Down Expand Up @@ -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.
Expand All @@ -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`,
Expand Down
160 changes: 146 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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]
```
Expand All @@ -179,18 +179,18 @@ oferece:

```php
$phpay->pix();
// NotImplementedException: Efí não suporta chaves Pix.
// NotImplementedException: Rede não suporta chaves Pix.
// Capacidades disponíveis: cobranças.
```

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:
Expand Down Expand Up @@ -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` |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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']);
```

---
Expand Down Expand Up @@ -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

Expand Down
5 changes: 4 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
"gateways",
"sdk asaas",
"sdk efi",
"pix",
"pix automatico",
"mtls",
"phpay"
],
"license": "BUSL-1.1",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions examples/efi/credentials.example.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading
Loading