Skip to content

Cobrança portável: um ChargeIntent com fromIntent() em cada gateway #173

Closed

Description

@mariolucasdev

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:

  1. valor escalar — Asaas, Woovi, Efí, Mercado Pago, Rede, Inter;
  2. 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. O CLAUDE.md já registra isso da AbacatePay — "cobrança é
    montada por produtos, não por valor"
    ;
  3. 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 centavos

As 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:

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

  1. mariolucasdev commented on Oct 26, 2026

    @mariolucasdev
    CollaboratorAuthor

    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:

    eixo como se divide
    1. meio de pagamento nem todo gateway faz todo meio (ver achado nº 1)
    2. unidade do valor reais decimal (Asaas, Mercado Pago) · centavos inteiros (Woovi, Efí, PagBank, Pagar.me, Cielo, Rede, AbacatePay) · reais em string (Inter, valor.original)
    3. forma do valor escalar · lista de itens (PagBank items, Pagar.me items, AbacatePay products)
    4. ligação do cliente por id (Asaas customer, Pagar.me customer_id, AbacatePay customerId) · inline (Woovi, Mercado Pago payer, Cielo Customer, PagBank customer) · ausente (Rede) · condicional (Inter: devedor só é obrigatório com vencimento)
    5. referência de quem integra correlationID (Woovi) · MerchantOrderId (Cielo) · reference (Rede) · txid (Inter) · inexistente nos outros cinco

    O Money já resolve o eixo 2 e o Customer já resolve o conteúdo do eixo
    4 — mas não a forma de ligação. Sobram para o ChargeIntent os eixos 1, 3,
    5 e a forma de ligação do 4.


    O VO

    final class ChargeIntent
    {
        private function __construct(
            public readonly Method    $method,       // PIX | BOLETO | CREDIT_CARD | DEBIT_CARD
            public readonly Money     $amount,       // sempre Money, sempre autoritativo
            public readonly ?Customer $customer,
            public readonly ?string   $dueDate,      // Y-m-d
            public readonly ?string   $description,
            public readonly ?string   $reference,    // o id de quem integra
            public readonly array     $items,        // Item[]; vazio = valor escalar
            public readonly array     $extra,        // por gateway, como Customer::withExtra()
        ) {
        }
    
        public static function pix(Money $amount, ?Customer $customer = null, ?string $dueDate = null, ...): self;
        public static function boleto(Money $amount, Customer $customer, string $dueDate, ...): self;
        public static function credito(Money $amount, Customer $customer, Card $card, int $installments = 1, ...): self;
        public static function debito(Money $amount, Customer $customer, Card $card, ...): self;
    
        /** o valor sai da soma dos itens, e não pode divergir dela */
        public static function comItens(Method $method, array $items, ...): self;
    }

    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 um
    arredondamento silencioso.

    São quatro casos em Method, não três: Cielo e Pagar.me validam
    DebitCard/debit_card como 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 Card próprio, exigido
    só por cartao(). Senão todo intent Pix carregaria cinco campos nulos de
    cartão, e a lib de pagamentos passaria a transitar PAN onde não precisa.


    A tabela de tradução, gateway a gateway

    gateway meios que ele faz o que fromIntent() monta
    Asaas pix, boleto, cartão billingType do Method, value = toReais(), dueDate, customer = id (cria antes, se o Customer não tiver), description
    Woovi pix correlationID = reference ?? Idempotency::key(), value = toCentavos(), customer inline
    Efí boleto em charge(), pix só em pixCharge() value = toCentavos(), expire_at = dueDate, description; itens viram items[]
    Mercado Pago pix, boleto, cartão transaction_amount = toReais(), payment_method_id do Method, payer inline (e-mail obrigatório)
    PagBank pix (qr_codes), cartão (charges) items[] sintetizado (name/quantity/unit_amount), qr_codes[0].amount.value em centavos, customer inline
    Pagar.me pix, boleto, cartão items[] (description/quantity/amount), payments[0].payment_method, customer inline ou customer_id
    Cielo pix, boleto, crédito, débito MerchantOrderId = reference, Payment.Type, Payment.Amount em centavos, Customer.Name
    Rede só crédito reference, amount em centavos, kind = credit, installments, dados do Card
    AbacatePay pix products[] (externalId, name, quantity, price ≥ 100), frequency = ONE_TIME, methods = ['PIX']
    Inter pix valor.original = reais em string, devedor = Customer, calendario.dataDeVencimento, e a chave (ver achado nº 3)

    A matriz que sai disso

    É exatamente o que supportedMethods() devolveria, e não existe em lugar
    nenhum hoje:

    gateway pix boleto crédito débito
    Asaas ✅ ✅ ✅ —
    Woovi ✅ — — —
    Efí ⚠️ só em pixCharge() ✅ — —
    Mercado Pago ✅ ✅ ✅ —
    PagBank ✅ — ✅ —
    Pagar.me ✅ ✅ ✅ ✅
    Cielo ✅ ✅ ✅ ✅
    Rede — — ✅ —
    AbacatePay ✅ — — —
    Inter ✅ — — —

    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'. O
    fromIntent() do Mercado Pago precisa de uma tradução própria de Method
    para 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á no
    repositório
    : Efi\Resources\Charge\Charge::getItems() faz exatamente isso
    quando ninguém chamou setItems(). A AbacatePay exige mínimo de 100 centavos
    por produto — se o valor for menor, levanta, não arredonda; arredondar
    cobraria o valor errado, que é o pecado que o Money existe para impedir.

    2. Itens → escalar. Soma, e o description vira o nome do primeiro item
    quando não houver description próprio. Como amount é 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. customer fica anulável no VO. Cada gateway
    que 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. installments e kind. Confirmado: são de cartão. Por isso cartao()
    é um construtor separado que exige Card e aceita installments, e pix()
    nem conhece esses campos. Três construtores, não um objeto com tudo anulável.

    5. O que não é comum. withExtra(), exatamente como o Customer, com o
    mesmo aviso do CLAUDE.md: campo obrigatório caindo no withExtra() é
    sinal de VO incompleto, não de solução.


    Os três achados que o desenho revelou

    1. SupportsCharges não diz qual meio — e isso fura o #174

    A capacidade diz "este gateway faz cobrança". Não diz que a Rede só faz
    crédito
    . Então, hoje, nada impede o FallbackGateway de cair de uma cobrança
    Pix 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 o
    FallbackGateway filtrando 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 gateway
    concreto, e por decisão registrada no CLAUDE.md (mudar o retorno de charge()
    quebraria a v2). Então ChargeIntent::pix() na Efí, pela facade, hoje não
    tem como funcionar
    .

    Três saídas, e eu escolheria a (b):

    • (a) charge() passa a despachar por Method — mudança de comportamento
      numa minor, e justamente a que a v2 evitou;
    • (b) supportedMethods() da Efí não lista PIX, e quem quer Pix na
      Efí continua em pixCharge(). Honesto, consistente com o achado nº 1, e o
      fallback simplesmente não oferece a Efí para Pix;
    • (c) fromIntent() da Efí aceita PIX e o Resource decide — esconde a
      divergê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 de setKey(), por cobrança. Mas
    é uma propriedade da conta: a mesma chave vale para todas as cobranças daquele
    clientId. Um fromIntent() não tem de onde tirá-la — e pedir que o intent a
    carregue seria colocar configuração de conta dentro da descrição da cobrança.

    Proposta: a chave vai para o construtor do InterGateway, ao lado de account
    e scopes, e fromIntent() a lê de lá. setKey() fica para quem tem mais de
    uma. 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

    fase o que entra por quê
    1 VO + Method + pix() + Asaas, Woovi, Mercado Pago, Inter são os de valor escalar que fazem Pix; cobre o caso mais pedido e prova o padrão
    2 comItens() + PagBank, Pagar.me, AbacatePay a conversão escalar → itens, com o mínimo da AbacatePay
    3 boleto(), cartao() + Card + Cielo, Rede, Efí(boleto) cartão traz PAN e merece revisão própria

    supportedMethods() entra na fase 1, porque o #174 depende dele.

    Tudo atrás de um método novo (createFrom(ChargeIntent) ou
    setIntent()->create()). setCharge(array) não é tocado — continua sendo
    o caminho de quem quer o payload cru daquele gateway, e nada aqui o substitui.


    O que não entra

    • Split e soft_descriptor: withExtra(), e só.
    • Assinaturas: outro ciclo de vida, como já decidido para o
      SubscriptionStatusEnum. ChargeIntent é cobrança avulsa.
    • Conversão de unidade nova: o Money já cobre reais, centavos e — com
      toDecimal() — 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.

  2. mariolucasdev commented on Oct 26, 2026

    @mariolucasdev
    CollaboratorAuthor

    Recomendação fechada para os três pontos

    Isto supera a saída (b) que eu propus para a Efí no comentário anterior.
    Olhando os contratos de perto, os pontos 1 e 2 se fecham com a mesma peça, e
    ela custa menos que a saída que eu tinha escolhido.


    Pontos 1 e 2 — uma interface nova

    interface SupportsChargeIntents extends SupportsCharges
    {
        /** @return array<int, Method> */
        public function supportedMethods(): array;
    
        /** @return array<mixed> */
        public function createCharge(ChargeIntent $intent): array;
    }

    Mais Capability::CHARGE_INTENTS e PHPay::createCharge(ChargeIntent) com o
    guard instanceof de sempre.

    É o padrão da casa, não invenção. SupportsRefunds extends SupportsCharges
    e declara uma operação (refund()), tem case na Capability e guard na
    facade. Esta é a mesma forma.

    Por que fecha os dois de uma vez

    • Ponto 1: a declaração de métodos mora na interface nova. SupportsCharges
      fica intacta.
    • Ponto 2: a Efí implementa e roteia por dentro — PIX vai para
      pixCharge(), BOLETO vai para charge(). Ninguém de fora precisa saber
      que são dois Resources, e o Pix da Efí volta para o caminho portável.

    Trade-offs

    A favor

    • Zero quebra. Acrescentar supportedMethods() a SupportsCharges quebra
      quem implementa a interface fora do repositório e exigiria uma major. Aqui
      não se toca nela.
    • Efi::charge(): Charge é tipado concreto — confirmei nos quatro
      gateways que olhei. Fazer charge() despachar por Method tornaria o
      retorno Charge|PixCharge e quebraria todo Charge $c = $efi->charge();
      que existe hoje.
    • O FallbackGateway passa a exigir SupportsChargeIntents, não
      SupportsCharges. 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

    • Mais uma coluna na matriz de capacidades (nove).
    • Dois caminhos para criar cobrança vão conviver por tempo indeterminado:
      charge()->setCharge([...])->create() e createCharge($intent). Isso é
      custo de documentação permanente, e é o preço de não quebrar a v2.
    • Um gateway de terceiros que não implemente a interface fica fora do
      fallback
      . É recusa explícita, e eu a prefiro ao palpite — mas é recusa.

    O que rejeito, e por quê

    saída veredito
    (a) charge() despacha por Method quebra o retorno tipado, e é exatamente a mudança que a v2 evitou
    (c) fromIntent() da Efí aceita PIX e o Resource decide esconde a divergência no lugar errado
    (b) Efí não declara PIX — minha proposta anterior honesta, mas cara: a Efí é PSP e faz Pix. Excluí-la do caminho portável por uma limitação que é só de roteamento interno é desistir fácil demais

    Ponto 3 — a chave Pix do Inter

    Recomendação: vai para a configuração — 'pixKey' em
    Gateway::INTER->options() — e no construtor depois de $client.
    setKey() continua, para quem tem mais de uma chave, e sobrepõe.

    Trade-offs

    A favor

    • O objeto nasce válido, como o resto da biblioteca. E o GatewayConfigRequest
      recusa chave desconhecida, então pixKey tem de entrar em options() de
      qualquer jeito — não há caminho em que a config não saiba dela.
    • Quem resolve pelo registry (Gateway::INTER->make($config)) nunca vê a ordem
      dos parâmetros.
    • Precedente no próprio Inter: account (o x-conta-corrente) também é dado
      de conta, não credencial, e já mora lá.

    Contra

    • A posição é feia. $client é o último parâmetro por convenção, e um
      parâ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.
    • É o primeiro parâmetro de gateway que é dado de negócio e não credencial
      nem ambiente. O account abre o precedente, mas vale saber que a porta fica
      aberta.

    Alternativa que considerei e descartei

    setPixKey() no gateway, chamado pelo registry depois de construir. Evita a
    ordem 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 pixKey
    em 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

    1. Interface nova SupportsChargeIntents, com supportedMethods() e
      createCharge(), seguindo a forma do SupportsRefunds.
    2. A Efí implementa e roteia por dentro — o Pix dela volta ao caminho
      portável sem mexer em charge().
    3. pixKey na config do Inter, no construtor depois de $client, com
      setKey() sobrepondo.

    Com os três fechados, a fase 1 fica: Method, ChargeIntent,
    SupportsChargeIntents, Capability::CHARGE_INTENTS, facade, e os quatro
    gateways de valor escalar que fazem Pix — Asaas, Woovi, Mercado Pago e Inter.

  3. mariolucasdev commented on Oct 26, 2026

    @mariolucasdev
    CollaboratorAuthor

    As três fases entraram

    fase PR commit
    1 — ChargeIntent, Method, SupportsChargeIntents, facade #175 6a92da1
    2 — Item e comItens() #176 bc9f894
    3 — boleto(), credito() e Card #178 17df683

    O #177 era a fase 3 e o GitHub o fechou sozinho quando a branch da fase 2
    sumiu no merge do #176. Virou #178 com o mesmo conteúdo.

    O que os dez aceitam hoje

    asaas        Pix, boleto
    woovi        Pix
    efi          boleto
    mercadopago  Pix
    pagbank      Pix
    pagarme      Pix, boleto
    cielo        Pix, boleto, cartão de crédito
    rede         cartão de crédito
    abacatepay   Pix
    inter        Pix
    

    Os dez declaram Capability::CHARGE_INTENTS. O que cada um aceita sai de
    supportedMethods(), 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 um CardToken ao lado do Card, e a
    pergunta de desenho é se Method::CREDIT_CARD passa a ter duas formas de
    pagamento ou se o ChargeIntent ganha um credito(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 o Customer
    nã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.

  4. mariolucasdev commented on Oct 26, 2026

    @mariolucasdev
    CollaboratorAuthor

    Entregue nas três fases — #175 (6a92da1), #176 (bc9f894) e #178 (17df683). Os dez gateways declaram Capability::CHARGE_INTENTS, e supportedMethods() diz o que cada um aceita.

    O que sobrou virou issue própria, porque são três assuntos com donos diferentes:

    Fechando esta.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions