Skip to content

PHPAY-173: feat: bill by items, where the gateway has no amount - #176

Merged
mariolucasdev merged 1 commit into
developfrom
feat/phpay-173-fase2
Oct 26, 2026
Merged

mariolucasdev merged 1 commit into
developfrom
feat/phpay-173-fase2

Conversation

@mariolucasdev

Copy link
Copy Markdown
Collaborator

Fase 2 de #173. Agora o ChargeIntent cobre Pix em sete dos dez.

O que entra

peça o que faz
PHPay\Support\Item uma linha de cobrança: nome, preço unitário, quantidade, referência
ChargeIntent::comItens() o valor é a soma das linhas, e continua autoritativo
ChargeIntent::itemsOrOne() a conversão escalar → linha, escrita uma vez
PagBank, Pagar.me, AbacatePay fromIntent() + supportedMethods() + createCharge()

Por que Item precisava existir

Três dos dez não têm valor na cobrança. O PagBank e o Pagar.me somam
items, a AbacatePay soma products — e cada um escreve a linha diferente:

gateway a linha
PagBank name, quantity, unit_amount
Pagar.me description, quantity, amount
AbacatePay externalId, name, quantity, price (mínimo 100)

As duas conversões, e o que custa cada uma

de para o que acontece
escalar gateway de itens vira uma linha só. Não é invenção: o getItems() da Efí já fazia isso no boleto, e agora está escrito num lugar em vez de três
itens gateway de valor escalar sai o total, e o detalhe das linhas se perde

A segunda é limite da API deles, e está documentada em vez de acontecer
calada. O dinheiro nunca se perde: amount é a soma e é o que vai — há teste
provando que um intent por itens na Woovi manda 14980 e nenhum items.

Três decisões que o desenho não previa

1. A AbacatePay exige duas URLs. É a única dos dez cuja cobrança é um
link de checkout
, então precisa de returnUrl e completionUrl. Elas são da
aplicação, não da descrição da cobrança, então vêm por withExtra() — e a
chamada recusa quando faltam, com 0 requisições, em vez de inventar uma.

2. O externalId é derivado do nome, não sorteado. É o id do produto no
seu sistema. Quando o Item traz reference, vai ele; sem ele, sha1 do
nome truncado — sortear daria ao mesmo produto um id novo a cada cobrança, e há
teste provando que duas cobranças iguais produzem o mesmo id.

3. O mínimo de R$ 1,00 da AbacatePay é recusado, nunca arredondado.
Arredondar cobraria o valor errado, que é o erro que o Money existe para
impedir. Recusa antes de qualquer HTTP.

Um teste meu da fase 1 quebrou, e estava certo quebrar

ChargeIntentAcrossGatewaysTest usava o PagBank como exemplo de gateway
que não declara a capacidade. Depois desta fase ele declara, então troquei o
exemplo pela Rede — que segue fora porque só faz crédito.

Dois erros do PHPStan que valem registro

  • @throws ValidationException|ApiException sem import, dentro de
    namespace PHPay\AbacatePay, resolveu para
    PHPay\AbacatePay\ValidationException. É a mesma armadilha de resolução que
    o CLAUDE.md descreve para PHPay\PHPay.
  • Item::sum() declarava @param array<int, self> e checava instanceof —
    PHPStan apontou que a checagem seria sempre verdadeira. O @param passou a
    ser array<mixed>, que é o que de fato chega: a função é a fronteira onde o
    array de quem chama vira item, e a checagem é o que torna a promessa
    verdadeira. É a forma que os *Request::validate() já usam.

Portão

  • 821 testes (+21), 2263 asserções
  • PHPStan nível 9 limpo
  • a matriz do README se regerou sozinha
  • smoke: 105 verificações, e agora sete gateways exercitam createCharge()
    e três exercitam a recusa
  • três listas de capacidade nos testes dos gateways foram atualizadas

Falta a fase 3

boleto(), cartao() e um VO Card, para Cielo, Rede e o boleto da Efí — e é
por ela que a Efí entra, roteando PIX para pixCharge() por dentro. A Rede
nunca entra em Pix: ela só faz crédito.

Three of the ten have no amount on the charge at all: the PagBank and
the Pagar.me build it from items, the AbacatePay from products. Item is
the one shape for a line that is spelled name/unit_amount, then
description/amount, then name/price.

A scalar intent becomes a single line, which is what the Efi charge
already did for billets, and an itemised intent on the seven that take a
scalar sends the total and drops the line detail. That second direction
is a limit of those APIs, so it is written down rather than silent - the
amount never changes, because it is the sum and it is authoritative.

The AbacatePay refuses a product under one real before any HTTP instead
of rounding up, since rounding charges the wrong value. Its externalId
comes from the item's reference and, without one, is derived from the
name: drawing a random id would give the same product a new identity on
every charge. It is also the only one of the ten that needs a return and
a completion URL, because its charge is a checkout link - those belong
to the application, so they arrive through withExtra() and are refused
when missing.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant