Um estorno parcial numa venda com split não é um campo refunded_amount na tabela de pagamentos. Ele é uma entidade própria, com uma linha de alocação por recebedor. Para funcionar, ele precisa de três decisões explícitas: como dividir o valor devolvido entre os recebedores, quem absorve a taxa do gateway e o que fazer quando o recebedor já sacou o dinheiro. Quando essas regras ficam implícitas no código, a soma dos estornos deixa de bater com o que cada um recebeu, e a diferença só aparece na conciliação.
Vou mostrar o modelo que uso em plataformas com split no Laravel e PostgreSQL. Split de pagamento é a divisão do valor de uma transação entre vários recebedores, como lojista, plataforma e parceiro, feita no momento da captura. O pagamento costuma receber todo o cuidado na implementação. O estorno, quando a venda já foi dividida, é onde o modelo costuma quebrar.
Por que estorno com split é mais difícil que o próprio pagamento?
No pagamento, a regra de divisão é conhecida antes do dinheiro entrar. Você calcula as partes, manda para o gateway (Pagar.me, Stripe Connect, Zoop, Asaas, cada um com sua API) e pronto. No estorno, várias coisas já aconteceram desde a venda:
- Parte do valor pode já ter sido devolvida em estornos anteriores.
- Cada recebedor pode ter sacado, antecipado ou ainda estar aguardando a liquidação.
- A taxa do gateway já foi descontada, e a devolução dela depende do adquirente e do contrato.
- O gateway confirma o estorno de forma assíncrona, às vezes horas depois.
- Pode chegar um segundo pedido de estorno enquanto o primeiro ainda está em processamento.
Se o modelo é só payments.refunded_amount, nenhuma dessas perguntas tem onde morar.
Como modelar estorno parcial no banco?
Uso duas tabelas. refunds guarda o pedido de devolução ao pagador. refund_allocations guarda quanto cada recebedor devolve naquele estorno.
Schema::create('refunds', function (Blueprint $table) { $table->id(); $table->foreignId('payment_id')->constrained(); $table->bigInteger('amount'); // centavos devolvidos ao pagador $table->string('status'); // pending, processing, succeeded, failed $table->string('reason')->nullable(); $table->string('idempotency_key')->unique(); $table->string('gateway_refund_id')->nullable()->unique(); $table->timestamps(); }); Schema::create('refund_allocations', function (Blueprint $table) { $table->id(); $table->foreignId('refund_id')->constrained(); $table->foreignId('payment_split_id')->constrained(); $table->bigInteger('amount'); // centavos que este recebedor devolve $table->bigInteger('fee_amount')->default(0); // parte da taxa atribuída a ele $table->timestamps(); });
A alocação aponta para a linha do split original, e não só para o recebedor. O mesmo recebedor pode aparecer duas vezes no split, por exemplo como vendedor e como afiliado em outra regra. O que precisa fechar é "quanto foi devolvido desta parte". Valores em centavos inteiros, sempre. Se ainda tem decimal ou float circulando no seu domínio, vale ler antes como tratar dinheiro no Laravel com centavos em inteiro e split sem perder centavo.
A invariante que esse modelo protege é simples de enunciar: para cada linha de split, a soma das alocações de estornos não falhos nunca passa do valor daquela linha. E a soma das alocações de um estorno é exatamente o valor do estorno.
Como dividir o valor estornado entre os recebedores?
A estratégia é decisão de produto, mas o código precisa suportar mais de uma. As que já precisei implementar:
| Estratégia | Como funciona | Quando faz sentido |
|---|---|---|
| Proporcional | Cada recebedor devolve na mesma proporção em que recebeu | Marketplace genérico, padrão mais defensável |
| Prioridade do lojista | Lojista devolve primeiro; plataforma só entra se o lojista não cobrir | Plataforma que não quer devolver comissão em estorno parcial |
| Por item | Estorno ligado a um item do pedido devolve só do vendedor daquele item | Pedido com vários vendedores |
| Manual | Operador define as partes no backoffice | Acordos comerciais, casos de exceção |
A proporcional parece trivial até o segundo estorno parcial. Se você calcula a proporção sempre sobre o split original e arredonda cada estorno de forma independente, os centavos de arredondamento se acumulam. No estorno final, que deveria zerar tudo, um recebedor acaba devolvendo um centavo a mais do que recebeu.
A solução é calcular a proporção sobre o saldo ainda estornável de cada parte, e não sobre o valor original. Junto com o método do maior resto (distribuir os centavos que sobram do arredondamento para baixo para quem teve a maior fração descartada), isso dá duas garantias: nenhuma parte devolve mais do que ainda tem, e o estorno que completa o total devolve exatamente o que restava de cada um.
final class ProportionalRefundAllocator { /** * @param array<int,int> $remaining payment_split_id => centavos ainda estornáveis * @return array<int,int> payment_split_id => centavos a devolver neste estorno */ public function allocate(array $remaining, int $amount): array { $total = array_sum($remaining); if ($amount <= 0 || $amount > $total) { throw new InvalidArgumentException('Valor de estorno fora do estornável.'); } $allocation = []; $fractions = []; foreach ($remaining as $splitId => $cents) { $allocation[$splitId] = intdiv($cents * $amount, $total); $fractions[$splitId] = ($cents * $amount) % $total; } $left = $amount - array_sum($allocation); // maior fração primeiro; empate resolvido pelo id para ser determinístico uksort($fractions, fn ($a, $b) => [$fractions[$b], $a] <=> [$fractions[$a], $b]); foreach (array_keys($fractions) as $splitId) { if ($left === 0) { break; } $allocation[$splitId]++; $left--; } return $allocation; } }
Por que isso nunca estoura uma parte? Se o estorno é menor que o total estornável, o valor exato de cada parte é estritamente menor que o que ela tem, então o arredondamento para baixo mais um centavo ainda cabe. Se o estorno é igual ao total, a conta é exata e ninguém recebe centavo extra. Quem tem zero restante tem fração zero e nunca entra na distribuição. O desempate pelo id importa: rodar a alocação duas vezes com a mesma entrada tem que dar o mesmo resultado, senão o reprocessamento gera divergência.
O trade-off: com essa abordagem, a alocação de um estorno depende do histórico. Dois estornos de R$ 10 não geram necessariamente as mesmas alocações em centavos. Para auditoria isso não é problema, porque cada alocação fica gravada. Mas vale avisar quem consome o relatório que não dá para recalcular de cabeça.
Como garantir que a soma dos estornos nunca passe do valor capturado?
Dois pedidos de estorno parcial simultâneos, cada um válido isoladamente, podem juntos passar do valor capturado. Isso acontece, por exemplo, quando o lojista clica duas vezes no backoffice ou quando o atendimento e a API pedem ao mesmo tempo. A defesa é a mesma de qualquer débito concorrente: travar a linha do pagamento e contar como comprometido tudo que não falhou, inclusive o que ainda está pendente.
public function request(int $paymentId, int $amount, string $idempotencyKey): Refund { return DB::transaction(function () use ($paymentId, $amount, $idempotencyKey) { $payment = Payment::whereKey($paymentId)->lockForUpdate()->firstOrFail(); if ($existing = Refund::where('idempotency_key', $idempotencyKey)->first()) { return $existing; } $remaining = $payment->splits->mapWithKeys(fn (PaymentSplit $split) => [ $split->id => $split->amount - $split->allocations() ->whereHas('refund', fn ($q) => $q->where('status', '!=', 'failed')) ->sum('amount'), ])->all(); $allocation = $this->allocator->allocate($remaining, $amount); $refund = $payment->refunds()->create([ 'amount' => $amount, 'status' => 'pending', 'idempotency_key' => $idempotencyKey, ]); foreach (array_filter($allocation) as $splitId => $cents) { $refund->allocations()->create([ 'payment_split_id' => $splitId, 'amount' => $cents, ]); } ProcessRefund::dispatch($refund->id)->afterCommit(); return $refund; }); }
Alguns pontos que parecem detalhe e não são:
- Pendente conta como comprometido. Se você só soma os
succeeded, o segundo pedido passa enquanto o primeiro está no gateway. O estorno pendente funciona como reserva. Se falhar, a reserva é liberada automaticamente, porque o filtro ignorafailed. - A chave de idempotência vem de quem pede. No backoffice, gero no front ao abrir o modal. Na API, exijo no header. Ela também vai para o gateway, para que um retry do job não crie dois estornos lá.
afterCommit()no dispatch. Sem isso, o job pode rodar antes do commit, não achar o estorno e falhar, ou pior, achar um estado que vai sofrer rollback.- A checagem de idempotência fica depois do lock. Assim, duas requisições com a mesma chave não passam juntas pela verificação.
A unique em idempotency_key é a última linha de defesa caso alguém chame esse código fora da transação.
Quem paga a taxa do gateway no estorno?
MDR (Merchant Discount Rate) é a taxa percentual que o adquirente cobra sobre a venda no cartão. No estorno, o comportamento varia por adquirente, por meio de pagamento e pelo que está no seu contrato. Em alguns casos a taxa volta proporcionalmente, em outros não volta, e às vezes há uma tarifa fixa de estorno. Não assuma: leia o contrato e confira o que o gateway devolve no payload do estorno.
Com o comportamento do gateway em mãos, sobra a decisão interna de quem absorve a taxa que não voltou:
| Opção | Efeito | Risco |
|---|---|---|
| Plataforma absorve | Recebedores devolvem exatamente o que receberam de líquido proporcional | Estornos frequentes corroem a margem da plataforma |
| Proporcional entre recebedores | Cada um arca com a fração da taxa equivalente à sua parte | Mais justo, mais difícil de explicar no extrato |
| Lojista absorve | Plataforma nunca perde em estorno | Atrito comercial, precisa estar no contrato |
Por isso existe fee_amount na alocação. O valor devolvido ao pagador (amount) e o custo que cada recebedor absorve são números diferentes, e o extrato do recebedor precisa mostrar os dois. Misturar tudo num campo só é o que gera o chamado "por que meu estorno de R$ 50 tirou R$ 51,20 do meu saldo?".
E se o recebedor já sacou o dinheiro?
Esse é o caso que separa um sistema de split de brinquedo de um de produção. O lojista vendeu, recebeu, sacou para a conta dele, e uma semana depois pede estorno parcial. A alocação diz que ele devolve R$ 80, mas o saldo dele é R$ 12. As saídas possíveis:
- Bloquear o estorno até haver saldo. Simples e seguro para a plataforma, ruim para o pagador e para o atendimento.
- Permitir saldo negativo com limite. O débito entra, o saldo fica negativo e futuras vendas compensam. Exige limite por recebedor e uma política de cobrança se ele parar de vender.
- Plataforma adianta e recupera depois. O estorno sai na hora com dinheiro da plataforma, e uma dívida do recebedor é registrada e abatida dos próximos recebíveis.
Na prática uso uma combinação: saldo negativo permitido até um limite definido pelo risco do recebedor, e acima disso o estorno fica bloqueado aguardando aprovação. O débito em si tem que ser feito com a mesma disciplina de qualquer débito de saldo, com lock e verificação dentro da transação, como descrevi em como evitar saldo negativo com débitos simultâneos no Laravel e PostgreSQL. A diferença é que aqui o limite inferior não é zero, é o limite de crédito do recebedor.
Quando a venda ainda não foi liquidada, o caminho é outro: em vez de debitar saldo, o estorno reduz os recebíveis futuros daquela venda. Em cartão parcelado, o adquirente costuma informar quais parcelas foram abatidas. O seu modelo deve aceitar essa resposta em vez de calcular por conta própria qual parcela cai, porque cada adquirente faz de um jeito. Se a sua agenda for bem modelada, isso vira um ajuste nas parcelas pendentes, como mostrei em agenda de recebíveis no Laravel com parcelas do cartão e antecipação. Se a parcela já foi antecipada, ela vira saldo efetivamente pago e cai no caso do recebedor que já sacou.
Como tratar a confirmação assíncrona do gateway?
O estorno tem ciclo de vida próprio: pending (criado no nosso lado), processing (aceito pelo gateway), succeeded ou failed. O job ProcessRefund chama a API, grava o gateway_refund_id e move para processing. A confirmação final quase sempre chega por webhook.
Regras que sigo nessa parte:
- O débito no saldo do recebedor acontece quando o estorno é criado, como reserva, e não quando o webhook chega. Se esperar o webhook, o recebedor pode sacar o dinheiro no meio tempo.
- No
failed, gero lançamentos de reversão no ledger. Não apago a alocação. O histórico precisa mostrar que houve reserva e liberação. - Busco o estorno pelo
gateway_refund_id, nunca pelo valor. Dois estornos de R$ 10 no mesmo pagamento são comuns. - O webhook é a fonte de verdade sobre o valor efetivamente devolvido. Se o gateway confirmar um valor diferente do pedido, o estorno vai para revisão manual em vez de ser ajustado automaticamente.
E valide a assinatura do webhook de estorno com o mesmo rigor do de pagamento. Um webhook forjado de refund.failed libera saldo que não deveria. Para conferir se o HMAC que você calcula bate com o que o gateway envia, o testador de webhook resolve sem precisar subir nada.
Vale lembrar também que estorno tem prazo. No Pix, a devolução pode ser solicitada em até 90 dias após a transação original. No cartão, a janela depende do adquirente. Passado o prazo, o estorno pelo gateway deixa de existir, e a devolução vira uma transferência comum, com outro fluxo e outra contabilização. Valido o prazo no request() e devolvo um erro de negócio claro, em vez de deixar o gateway rejeitar.
Conclusão: o que implementar primeiro
Se você está começando ou herdou um sistema com refunded_amount numa coluna, a ordem que eu seguiria é:
- Criar
refundserefund_allocations, com alocação por linha de split e chave de idempotência única. - Travar o pagamento com
lockForUpdatee contar pendentes como comprometidos ao validar o valor estornável. - Alocar proporcionalmente sobre o saldo restante de cada parte, com maior resto e desempate determinístico.
- Separar o valor devolvido ao pagador do custo de taxa absorvido por cada recebedor.
- Definir por escrito a política para recebedor sem saldo (limite negativo, bloqueio ou adiantamento) antes de precisar dela.
- Debitar na criação como reserva e reverter com lançamento no
failed.
O teste que eu escreveria antes de qualquer outro: uma venda com três recebedores, uma sequência de estornos parciais com valores quebrados e, no fim, um estorno do restante. A asserção é que cada linha de split terminou com exatamente zero estornável e que nenhum estorno intermediário deixou parte negativa. Se esse teste passa com valores aleatórios rodando algumas centenas de vezes, a parte mais traiçoeira do estorno com split está resolvida. O resto é política de negócio, e essa precisa estar documentada, não escondida num if.