# Estorno parcial com split de pagamento no Laravel: quem devolve quanto e quando

> Como modelar estorno parcial em vendas com split: alocação proporcional sem perder centavo, teto do valor estornável, taxa do gateway e recebedor que já sacou.

Por Lucas Roberto · Pagamentos · publicado em 2026-11-01 · 11 min de leitura
Versão web: https://lucasroberto.com/blog/estorno-parcial-com-split-de-pagamento-no-laravel-quem-devolve-quanto-e-quando

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:

1. Parte do valor pode já ter sido devolvida em estornos anteriores.
2. Cada recebedor pode ter sacado, antecipado ou ainda estar aguardando a liquidação.
3. A taxa do gateway já foi descontada, e a devolução dela depende do adquirente e do contrato.
4. O gateway confirma o estorno de forma assíncrona, às vezes horas depois.
5. 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.

```php
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](/blog/dinheiro-no-laravel-centavos-em-inteiro-cast-de-money-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.

```php
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.

```php
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:

1. **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 ignora `failed`.
2. **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á.
3. **`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.
4. **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:

1. **Bloquear o estorno até haver saldo.** Simples e seguro para a plataforma, ruim para o pagador e para o atendimento.
2. **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.
3. **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](/blog/como-evitar-saldo-negativo-com-debitos-simultaneos-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](/blog/agenda-de-recebiveis-no-laravel-como-modelar-parcelas-do-cartao-d30-e-antecipacao). 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:

1. **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.
2. **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.
3. **Busco o estorno pelo `gateway_refund_id`, nunca pelo valor.** Dois estornos de R$ 10 no mesmo pagamento são comuns.
4. **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](/ferramentas/testador-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 é:

1. Criar `refunds` e `refund_allocations`, com alocação por linha de split e chave de idempotência única.
2. Travar o pagamento com `lockForUpdate` e contar pendentes como comprometidos ao validar o valor estornável.
3. Alocar proporcionalmente sobre o saldo restante de cada parte, com maior resto e desempate determinístico.
4. Separar o valor devolvido ao pagador do custo de taxa absorvido por cada recebedor.
5. Definir por escrito a política para recebedor sem saldo (limite negativo, bloqueio ou adiantamento) antes de precisar dela.
6. 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`.

---

Lucas Roberto é desenvolvedor back-end e fundador da Granpag. Mais textos: https://lucasroberto.com/blog
