Repository navigation
Description
O problema
Não existe forma portável de descrever uma cobrança. Quem quer trocar de
gateway — ou cair para outro quando o primeiro falha — precisa escrever um
payload por gateway, que é exatamente o trabalho que a biblioteca deveria
tirar das costas de quem integra.
Levantei os campos obrigatórios dos dez, direto dos validadores:
| gateway | campos obrigatórios |
|---|---|
| Asaas | billingType, customer, dueDate, value |
| Woovi | correlationID, customer, value |
| Efí | description, expire_at, value |
| Mercado Pago | payer, payment_method_id, transaction_amount |
| PagBank | customer, items, charges, qr_codes |
| Pagar.me | customer, customer_id, items, payments |
| Cielo | Customer, MerchantOrderId, Payment |
| Rede | amount, installments, kind, reference |
| AbacatePay | customer, customerId, frequency, methods, products |
| Inter | calendario, chave, devedor, valor |
E o create() não tem a mesma assinatura: oito são create(), a Woovi é
create(Money|int $value) e o Inter é create(?string $txid = null).
O que isso não é
Não é só grafia diferente do mesmo campo. São três formas estruturais:
- valor escalar — Asaas, Woovi, Efí, Mercado Pago, Rede, Inter;
- lista de itens — PagBank (
items), Pagar.me (items), AbacatePay
(products). Nesses três não existe "valor da cobrança": o valor é a
soma dos itens. OCLAUDE.mdjá registra isso da AbacatePay — "cobrança é
montada por produtos, não por valor"; - objeto de pagamento aninhado — Cielo (
Payment), PagBank (charges),
Pagar.me (payments).
Somado a isso: a Cielo usa PascalCase enquanto os outros usam camelCase ou
snake_case; o Inter usa português (valor, devedor, calendario) porque
é a especificação do BACEN quase literal; e a Rede não tem cliente nenhum —
só amount, installments, kind e reference.
A proposta
Um VO PHPay\Support\ChargeIntent e um fromIntent(ChargeIntent): array em
cada *ChargeRequest — exatamente o padrão que Customer e
fromCustomer() já usam para resolver as seis grafias do campo de documento.
O mapeamento continua morando na classe *Request de cada gateway, que já
detém o conhecimento daquele schema.
$intent = ChargeIntent::pix(
amount: Money::reais('149,90'),
customer: $customer,
dueDate: '2026-11-10',
);
AsaasChargeRequest::fromIntent($intent); // billingType, dueDate, value em reais
WooviChargeRequest::fromIntent($intent); // correlationID, value em centavosAs perguntas difíceis, que o desenho precisa responder antes do código
1. Escalar → lista de itens. Já existe precedente dentro do repositório: o
getItems() da Efí sintetiza um item a partir de description + value
quando ninguém chamou setItems(). O mesmo caminho serve para PagBank,
Pagar.me e AbacatePay — mas a AbacatePay tem mínimo de 100 centavos por
produto, então a síntese precisa saber recusar.
2. Lista de itens → escalar. O caminho inverso é somar, e some informação.
Aceitável? Provavelmente sim, com o description virando o nome do primeiro
item ou um rótulo genérico — mas tem que ser decisão escrita, não acidente.
3. A Rede não tem cliente. Um ChargeIntent com customer obrigatório não
mapeia para ela. Ou customer é opcional no VO (e aí cada gateway que o exige
valida por conta), ou a Rede fica fora da portabilidade de criação. Eu ficaria
com a primeira.
4. installments e kind existem na Rede e na Cielo e não existem em
Pix. São campos de cartão. Isso sugere que ChargeIntent não é um objeto só:
talvez ChargeIntent::pix(), ::boleto() e ::cartao(), cada um com o que
aquele meio de fato tem.
5. O que não é comum a ninguém — soft_descriptor da Cielo, split,
frequency da AbacatePay. A resposta provavelmente é a mesma do Customer:
um withExtra() por gateway, com o mesmo aviso que já está no CLAUDE.md —
campo obrigatório caindo no withExtra() é sinal de que o VO está faltando
algo.
Por que isto vem primeiro
Duas coisas que queremos dependem só desta:
- FallbackGateway: cair para o próximo gateway só quando ficou provado que nada saiu #174 (
FallbackGateway) — sem intent portável, cair para
o próximo gateway exigiria que a aplicação passasse N payloads; - o trait
Cobravelno bridge (trait Cobravel: emitir cobrança a partir do User, no formato do Cashier phpay-laravel#13) —
$user->cobranca()->pix()->de(...)precisa de algo para onde traduzir.
Risco
Este é o maior dos três e o único que define vocabulário público. Depois de
publicado não sai sem major. Vale desenhar contra os dez antes de escrever
a primeira linha, e a entrega pode ser incremental: o VO e dois ou três
gateways primeiro, atrás de um método novo que não mexe no setCharge()
existente.
Fora de escopo
Não mexer em setCharge(array). Ele continua sendo o caminho de quem quer o
payload cru daquele gateway, e nada do que está aqui o substitui.
Activity
mariolucasdev commentedon Oct 26, 2026
Desenho, antes do código
Li os dez validadores de cobrança. O desenho abaixo sai daí, e três coisas
que eu não tinha previsto apareceram — uma delas muda o #174.
Os cinco eixos de divergência
A tabela de campos obrigatórios do corpo da issue esconde o problema real. Os
dez discordam em cinco eixos independentes, e o VO precisa nomear cada um:
valor.original)items, Pagar.meitems, AbacatePayproducts)customer, Pagar.mecustomer_id, AbacatePaycustomerId) · inline (Woovi, Mercado Pagopayer, CieloCustomer, PagBankcustomer) · ausente (Rede) · condicional (Inter:devedorsó é obrigatório com vencimento)correlationID(Woovi) ·MerchantOrderId(Cielo) ·reference(Rede) ·txid(Inter) · inexistente nos outros cincoO
Moneyjá resolve o eixo 2 e oCustomerjá resolve o conteúdo do eixo4 — mas não a forma de ligação. Sobram para o
ChargeIntentos eixos 1, 3,5 e a forma de ligação do 4.
O VO
amounté sempre autoritativo e sempre presente, mesmo quando há itens.Isso responde a ambiguidade das perguntas 1 e 2 de uma vez: não existe intent
em que o valor precise ser descoberto.
comItens()calcula a soma e a grava;passar itens cujo total diverge do
amounté erro de validação, não umarredondamento silencioso.
São quatro casos em
Method, não três: Cielo e Pagar.me validamDebitCard/debit_cardcomo tipo próprio, e tratar débito como "cartão"perderia a distinção que esses dois fazem.
Dados de cartão não entram no VO genérico. Vão num
Cardpróprio, exigidosó por
cartao(). Senão todo intent Pix carregaria cinco campos nulos decartão, e a lib de pagamentos passaria a transitar PAN onde não precisa.
A tabela de tradução, gateway a gateway
fromIntent()montabillingTypedoMethod,value=toReais(),dueDate,customer= id (cria antes, se oCustomernão tiver),descriptioncorrelationID=reference??Idempotency::key(),value=toCentavos(),customerinlinecharge(), pix só empixCharge()value=toCentavos(),expire_at=dueDate,description; itens viramitems[]transaction_amount=toReais(),payment_method_iddoMethod,payerinline (e-mail obrigatório)qr_codes), cartão (charges)items[]sintetizado (name/quantity/unit_amount),qr_codes[0].amount.valueem centavos,customerinlineitems[](description/quantity/amount),payments[0].payment_method,customerinline oucustomer_idMerchantOrderId=reference,Payment.Type,Payment.Amountem centavos,Customer.Namereference,amountem centavos,kind=credit,installments, dados doCardproducts[](externalId,name,quantity,price≥ 100),frequency=ONE_TIME,methods=['PIX']valor.original= reais em string,devedor=Customer,calendario.dataDeVencimento, e achave(ver achado nº 3)A matriz que sai disso
É exatamente o que
supportedMethods()devolveria, e não existe em lugarnenhum hoje:
pixCharge()Lida assim, a tabela diz uma coisa que a matriz de capacidades do README não
diz: "faz cobrança" é verdade nos dez, e mesmo assim só quatro fazem boleto
e só dois fazem débito.
Um detalhe de mapeamento que a tabela esconde: no Mercado Pago
payment_method_idé string livre no validador, e os valores reais não são'pix'/'boleto'uniformemente — boleto lá é'bolbradesco'. OfromIntent()do Mercado Pago precisa de uma tradução própria deMethodpara os ids dele, não de um
strtolower().As cinco perguntas do corpo da issue, respondidas
1. Escalar → lista de itens. Sintetiza um item com
description(ou'Cobrança') e o valor inteiro, quantidade 1. O precedente já está norepositório:
Efi\Resources\Charge\Charge::getItems()faz exatamente issoquando ninguém chamou
setItems(). A AbacatePay exige mínimo de 100 centavospor produto — se o valor for menor, levanta, não arredonda; arredondar
cobraria o valor errado, que é o pecado que o
Moneyexiste para impedir.2. Itens → escalar. Soma, e o
descriptionvira o nome do primeiro itemquando não houver
descriptionpróprio. Comoamounté sempre autoritativo,na prática essa conversão nunca precisa acontecer na hora de montar o payload —
o valor já está lá. Some só o detalhe dos itens, e isso está documentado em vez
de ser acidente.
3. A Rede não tem cliente.
customerfica anulável no VO. Cada gatewayque o exige valida por conta, com a mensagem dele. Mas a Rede sai da
portabilidade por outro motivo, muito mais forte — achado nº 1.
4.
installmentsekind. Confirmado: são de cartão. Por issocartao()é um construtor separado que exige
Carde aceitainstallments, epix()nem conhece esses campos. Três construtores, não um objeto com tudo anulável.
5. O que não é comum.
withExtra(), exatamente como oCustomer, com omesmo aviso do
CLAUDE.md: campo obrigatório caindo nowithExtra()ésinal de VO incompleto, não de solução.
Os três achados que o desenho revelou
1.
SupportsChargesnão diz qual meio — e isso fura o #174A capacidade diz "este gateway faz cobrança". Não diz que a Rede só faz
crédito. Então, hoje, nada impede o
FallbackGatewayde cair de uma cobrançaPix na Woovi para a Rede, que não sabe fazer Pix — e a falha viria como
erro de validação lá dentro, depois de já ter desistido do gateway anterior.
Proposta:
supportedMethods(): array<Method>no contrato de cobrança, e oFallbackGatewayfiltrando a lista pelo método do intent antes de tentar.Sem isso, a lista de fallback é uma armadilha.
Isto precisa entrar no #174.
2. A Efí não alcança Pix por
charge()charge()na Efí é boleto; a cobrança Pix épixCharge(), extra do gatewayconcreto, e por decisão registrada no
CLAUDE.md(mudar o retorno decharge()quebraria a v2). Então
ChargeIntent::pix()na Efí, pela facade, hoje nãotem como funcionar.
Três saídas, e eu escolheria a (b):
charge()passa a despachar porMethod— mudança de comportamentonuma minor, e justamente a que a v2 evitou;
supportedMethods()da Efí não listaPIX, e quem quer Pix naEfí continua em
pixCharge(). Honesto, consistente com o achado nº 1, e ofallback simplesmente não oferece a Efí para Pix;
fromIntent()da Efí aceitaPIXe o Resource decide — esconde adivergência no lugar errado.
3. A chave Pix do Inter é da conta, não da cobrança
O
chaveé obrigatório no payload e hoje vem desetKey(), por cobrança. Masé uma propriedade da conta: a mesma chave vale para todas as cobranças daquele
clientId. UmfromIntent()não tem de onde tirá-la — e pedir que o intent acarregue seria colocar configuração de conta dentro da descrição da cobrança.
Proposta: a chave vai para o construtor do
InterGateway, ao lado deaccounte
scopes, efromIntent()a lê de lá.setKey()fica para quem tem mais deuma. Isso mexe na assinatura do gateway, então é mudança que precisa de decisão
sua antes de eu encostar.
Entrega em três fases
Method+pix()+ Asaas, Woovi, Mercado Pago, IntercomItens()+ PagBank, Pagar.me, AbacatePayboleto(),cartao()+Card+ Cielo, Rede, Efí(boleto)supportedMethods()entra na fase 1, porque o #174 depende dele.Tudo atrás de um método novo (
createFrom(ChargeIntent)ousetIntent()->create()).setCharge(array)não é tocado — continua sendoo caminho de quem quer o payload cru daquele gateway, e nada aqui o substitui.
O que não entra
soft_descriptor:withExtra(), e só.SubscriptionStatusEnum.ChargeIntenté cobrança avulsa.Moneyjá cobre reais, centavos e — comtoDecimal()— a string do Inter. Nada novo é preciso.Se o desenho estiver de pé, o que eu quero fechado antes de abrir código são as
três decisões dos achados:
supportedMethods()no contrato, a saída (b)para a Efí, e a chave do Inter indo para o construtor.
mariolucasdev commentedon Oct 26, 2026
Recomendação fechada para os três pontos
Pontos 1 e 2 — uma interface nova
Mais
Capability::CHARGE_INTENTSePHPay::createCharge(ChargeIntent)com oguard
instanceofde sempre.É o padrão da casa, não invenção.
SupportsRefunds extends SupportsChargese declara uma operação (
refund()), tem case naCapabilitye guard nafacade. Esta é a mesma forma.
Por que fecha os dois de uma vez
SupportsChargesfica intacta.
PIXvai parapixCharge(),BOLETOvai paracharge(). Ninguém de fora precisa saberque são dois Resources, e o Pix da Efí volta para o caminho portável.
Trade-offs
A favor
supportedMethods()aSupportsChargesquebraquem implementa a interface fora do repositório e exigiria uma major. Aqui
não se toca nela.
Efi::charge(): Chargeé tipado concreto — confirmei nos quatrogateways que olhei. Fazer
charge()despachar porMethodtornaria oretorno
Charge|PixChargee quebraria todoCharge $c = $efi->charge();que existe hoje.
FallbackGatewaypassa a exigirSupportsChargeIntents, nãoSupportsCharges. A armadilha Woovi→Rede some por construção: não dápara pôr na lista de fallback um gateway que não declara o que faz.
Contra, e são custos reais
charge()->setCharge([...])->create()ecreateCharge($intent). Isso écusto de documentação permanente, e é o preço de não quebrar a v2.
fallback. É recusa explícita, e eu a prefiro ao palpite — mas é recusa.
O que rejeito, e por quê
charge()despacha porMethodfromIntent()da Efí aceitaPIXe o Resource decidePIX— minha proposta anteriorPonto 3 — a chave Pix do Inter
Recomendação: vai para a configuração —
'pixKey'emGateway::INTER->options()— e no construtor depois de$client.setKey()continua, para quem tem mais de uma chave, e sobrepõe.Trade-offs
A favor
GatewayConfigRequestrecusa chave desconhecida, então
pixKeytem de entrar emoptions()dequalquer jeito — não há caminho em que a config não saiba dela.
Gateway::INTER->make($config)) nunca vê a ordemdos parâmetros.
account(ox-conta-corrente) também é dadode conta, não credencial, e já mora lá.
Contra
$clienté o último parâmetro por convenção, e umparâmetro de configuração depois dele contraria o hábito. Mas inserir antes
quebraria construção posicional de quem já passa
$client— ele é o 7ºparâmetro hoje, confirmado. A feiura é o preço de não quebrar; numa v3 isso
se reordena.
nem ambiente. O
accountabre o precedente, mas vale saber que a porta ficaaberta.
Alternativa que considerei e descartei
setPixKey()no gateway, chamado pelo registry depois de construir. Evita aordem feia, mas cria um estado "construído e ainda inválido" que só falha em
runtime — contra o hábito da lib de validar na entrada.
Decorrência que não entra agora
O webhook da Efí também é endereçado por chave Pix. Se o Inter ganhar
pixKeyem config, cabe a mesma coisa lá. É outra issue, e misturar as duas
atrapalharia a revisão desta.
As três frases, se for para decidir rápido
SupportsChargeIntents, comsupportedMethods()ecreateCharge(), seguindo a forma doSupportsRefunds.portável sem mexer em
charge().pixKeyna config do Inter, no construtor depois de$client, comsetKey()sobrepondo.Com os três fechados, a fase 1 fica:
Method,ChargeIntent,SupportsChargeIntents,Capability::CHARGE_INTENTS, facade, e os quatrogateways de valor escalar que fazem Pix — Asaas, Woovi, Mercado Pago e Inter.
PHPAY-173: feat: describe a charge once, for more than one gateway (#175
mariolucasdev commentedon Oct 26, 2026
As três fases entraram
ChargeIntent,Method,SupportsChargeIntents, facade6a92da1ItemecomItens()bc9f894boleto(),credito()eCard17df683O que os dez aceitam hoje
Os dez declaram
Capability::CHARGE_INTENTS. O que cada um aceita sai desupportedMethods(), e a chamada recusa o resto antes de qualquer HTTP,nomeando o que o gateway faz.
O que fica aberto, e não é "mais um gateway"
1. Cartão onde o número não chega ao servidor. Mercado Pago quer um token
do SDK de navegador deles; PagBank e Pagar.me querem cifrado ou tokenizado; o
Asaas aceita token ou número, mas só tem isso modelado em assinatura. Isso
não é mais um
fromIntent()— pede umCardTokenao lado doCard, e apergunta de desenho é se
Method::CREDIT_CARDpassa a ter duas formas depagamento ou se o
ChargeIntentganha umcredito(CardToken)separado.2. Débito em qualquer um. Cielo e Pagar.me conhecem o tipo, mas ele pede
autenticação 3DS, que a biblioteca não modela em lugar nenhum. É trabalho de
fluxo, não de mapeamento.
3. O boleto do Mercado Pago. Mecanicamente é
payment_method_id=bolbradesco, mas o boleto lá exige endereço do pagador, e oCustomernão tem endereço. Deixei fora de propósito: ou o VO ganha endereço — decisão
que afeta os dez —, ou fica em
withExtra()e isso precisa estar documentado.Não quis escolher sozinho.
Sugiro fechar esta issue e abrir uma por item, porque os três têm donos
diferentes: o 1 é desenho de VO, o 2 é fluxo de autenticação, o 3 é uma
decisão sobre o
Customer.mariolucasdev commentedon Oct 26, 2026
Entregue nas três fases — #175 (
6a92da1), #176 (bc9f894) e #178 (17df683). Os dez gateways declaramCapability::CHARGE_INTENTS, esupportedMethods()diz o que cada um aceita.O que sobrou virou issue própria, porque são três assuntos com donos diferentes:
Card)Customernão tem endereço, e dois gateways já precisam deleFechando esta.