# Split de pagamento sem perder centavos: como dividir valores e estornos no Laravel

> Como dividir um pagamento entre vários recebedores sem perder nem criar centavos: inteiros, método do maior resto, regra explícita para o resto e estorno parcial rateado sobre o saldo.

Por Lucas Roberto · Pagamentos · publicado em 2026-10-09 · 6 min de leitura
Versão web: https://lucasroberto.com/blog/split-de-pagamento-sem-perder-centavos-como-dividir-valores-e-estornos-no-laravel

Para dividir um pagamento entre vários recebedores sem perder nem criar centavos, trabalhe sempre com inteiros em centavos. Calcule cada parte com arredondamento para baixo e distribua os centavos que sobrarem por um critério determinístico, como o método do maior resto. Persista as partes calculadas, nunca as recalcule depois, e faça o rateio de estornos parciais sobre o saldo restante de cada recebedor. Assim a soma sempre fecha com o total.

Parece detalhe, mas é o tipo de detalhe que aparece na conciliação. O relatório do adquirente diz uma coisa, o seu banco diz outra, e a diferença é de um centavo espalhado por milhares de transações. Abaixo está o modelo que eu uso hoje para split em sistemas de pagamento com Laravel e PostgreSQL.

## O que é split de pagamento e onde o arredondamento entra?

Split de pagamento é a divisão de uma única cobrança entre dois ou mais recebedores. Um caso típico é marketplace e lojista. Outro é a plataforma que fica com uma comissão e repassa o resto. Cada recebedor tem sua própria carteira no gateway (Pagar.me, Asaas, Zoop, Stripe Connect), e o gateway liquida para cada um a parte que você informou.

O problema é que porcentagem de um valor em centavos raramente dá um número inteiro. Quinze por cento de R$ 33,33 são 499,95 centavos. Se a plataforma arredonda para 500 e o lojista também arredonda a parte dele para cima, você acabou de distribuir um centavo que não existe. O gateway rejeita a requisição, ou aceita e alguém absorve a diferença sem ninguém perceber.

## Por que não usar float para dinheiro no PHP?

Porque float é uma representação binária aproximada. `0.1 + 0.2` não é `0.3`, e qualquer `round()` em cima disso herda a imprecisão. Em PHP isso aparece em lugares discretos: um cast de `decimal` vindo do banco como string que alguém converte com `(float)`, ou um `* 0.15` no meio de um service.

As regras que sigo:

1. **Coluna `bigint` em centavos** no PostgreSQL, nunca `decimal` convertido para float na aplicação.
2. **Porcentagens em basis points.** Basis point é um centésimo de ponto percentual, então 15% vira o inteiro `1500` de um total de `10000`.
3. **Divisão inteira explícita** com `intdiv()` e resto com `%`. Sem `round()` espalhado pelo código.
4. **Conversão para reais só na borda**, ou seja, na serialização para a API ou na view.

Se a API do gateway recebe valores em reais com decimais, a conversão acontece no client HTTP daquela integração e em nenhum outro lugar.

## Como dividir um valor em partes que somem exatamente o total?

O algoritmo que resolve isso é o método do maior resto (largest remainder method). Ele calcula a parte inteira de cada recebedor arredondando para baixo, vê quantos centavos sobraram e entrega um centavo para cada um dos recebedores com maior resto fracionário, até zerar a sobra.

```php
final class Allocator
{
    /**
     * @param  int  $total  valor em centavos (>= 0)
     * @param  array<string|int, int>  $weights  pesos inteiros (>= 0), ex.: basis points
     * @return array<string|int, int>
     */
    public static function allocate(int $total, array $weights): array
    {
        $sum = array_sum($weights);

        if ($total < 0 || $sum <= 0) {
            throw new InvalidArgumentException('Total negativo ou pesos inválidos.');
        }

        $parts = [];
        $remainders = [];

        foreach ($weights as $key => $weight) {
            $raw = $total * $weight;
            $parts[$key] = intdiv($raw, $sum);
            $remainders[$key] = $raw % $sum;
        }

        $left = $total - array_sum($parts);

        // Ordenação estável (PHP 8+): empates respeitam a ordem original das chaves.
        arsort($remainders);

        foreach (array_keys($remainders) as $key) {
            if ($left === 0) {
                break;
            }
            $parts[$key]++;
            $left--;
        }

        return $parts;
    }
}
```

Por exemplo, R$ 100,00 dividido igualmente entre três recebedores:

```php
Allocator::allocate(10000, ['a' => 1, 'b' => 1, 'c' => 1]);
// ['a' => 3334, 'b' => 3333, 'c' => 3333]
```

Com comissão de 15% para a plataforma sobre R$ 33,33:

```php
Allocator::allocate(3333, ['plataforma' => 1500, 'lojista' => 8500]);
// ['plataforma' => 500, 'lojista' => 2833]
```

A soma é sempre o total, e nenhuma parte se afasta mais de um centavo do valor exato. Duas restrições que deixo explícitas: o total não pode ser negativo, porque `intdiv` e `%` com sinal complicam a lógica e estorno é outro fluxo, e os pesos são inteiros. Com valores de pagamento e basis points, `$total * $weight` fica muito longe do limite de um inteiro de 64 bits.

## Quem deve ficar com o centavo que sobra no split?

O maior resto é justo matematicamente, mas nem sempre é a regra de negócio que você quer. O importante é que exista uma regra escrita e que ela seja determinística, ou seja, que a mesma entrada gere sempre a mesma saída. As opções que costumo ver:

| Critério | Como funciona | Quando usar |
|---|---|---|
| Maior resto | Centavo vai para quem tem a maior fração descartada | Divisão entre pares, sem hierarquia |
| Recebedor principal | Toda sobra vai para um recebedor fixo (normalmente o lojista) | Marketplace em que a plataforma cobra comissão |
| Plataforma absorve | Plataforma arredonda a própria parte para baixo e o resto vai para os outros | Quando você quer deixar claro que a comissão nunca é arredondada a seu favor |
| Ordem fixa | Sobra distribuída na ordem de cadastro dos recebedores | Regras contratuais que definem prioridade |

Para o caso clássico de dois recebedores com comissão, nem preciso do alocador. Calculo a comissão com arredondamento para baixo e o lojista recebe `total - comissão`. Subtrair em vez de calcular as duas partes independentemente garante a soma por construção.

Vale conferir o que o gateway faz, porque alguns têm configuração própria para isso. No Pagar.me, as regras de split têm opções como `charge_remainder_fee`, que define qual recebedor fica com o resto das taxas. Se a sua regra diz uma coisa e a configuração do gateway diz outra, a divergência aparece na liquidação.

## Como guardar o split no banco para não recalcular depois?

Calcule o split uma vez, no momento da cobrança, e persista cada parte. Recalcular depois a partir de porcentagens é pedir para dar diferente: a comissão do lojista mudou, alguém trocou a regra de arredondamento, ou o peso foi editado no painel.

Uma tabela `payment_splits` com `payment_id`, `recipient_id`, `amount` e `refunded_amount`, todos os valores em `bigint`, resolve. No PostgreSQL, deixo o banco proteger os invariantes mais baratos:

```php
Schema::create('payment_splits', function (Blueprint $table) {
    $table->id();
    $table->foreignId('payment_id')->constrained();
    $table->foreignId('recipient_id')->constrained();
    $table->bigInteger('amount');
    $table->bigInteger('refunded_amount')->default(0);
    $table->timestamps();
    $table->unique(['payment_id', 'recipient_id']);
});

DB::statement('ALTER TABLE payment_splits ADD CONSTRAINT splits_amounts_check
    CHECK (amount >= 0 AND refunded_amount >= 0 AND refunded_amount <= amount)');
```

A garantia de que a soma dos splits é igual ao valor do pagamento fica na aplicação, validada na mesma transação que cria as linhas. Daria para fazer com constraint trigger, mas para mim o custo de manutenção não compensa quando existe um único ponto de criação.

## Como ratear um estorno parcial entre os recebedores?

Estorno parcial é onde a maioria das implementações quebra. O erro comum é aplicar a porcentagem original sobre o valor estornado. Depois de dois ou três estornos parciais, os arredondamentos se acumulam e o último estorno tenta tirar de um recebedor mais do que ele ainda tem.

A solução é ratear o estorno usando como peso o **saldo restante** de cada recebedor (`amount - refunded_amount`), não a porcentagem original. Com o mesmo alocador, isso tem uma propriedade útil: quando o valor estornado é igual ao saldo total restante, cada recebedor devolve exatamente o próprio saldo. O estorno final sempre fecha, sem caso especial no código.

```php
public function refund(Payment $payment, int $amount): void
{
    DB::transaction(function () use ($payment, $amount) {
        $splits = $payment->splits()->lockForUpdate()->get();

        $remaining = $splits->mapWithKeys(
            fn (PaymentSplit $s) => [$s->id => $s->amount - $s->refunded_amount]
        )->all();

        if ($amount <= 0 || $amount > array_sum($remaining)) {
            throw new InvalidRefundAmount($payment, $amount);
        }

        $shares = Allocator::allocate($amount, $remaining);

        foreach ($splits as $split) {
            if ($shares[$split->id] > 0) {
                $split->increment('refunded_amount', $shares[$split->id]);
            }
        }
    });
}
```

O `lockForUpdate()` existe porque dois estornos parciais podem chegar ao mesmo tempo, por exemplo um pelo painel e outro por webhook. Sem o lock, os dois leem o mesmo saldo e você estorna mais do que deveria. A constraint `refunded_amount <= amount` é a última linha de defesa se algo escapar.

Chargeback, a contestação aberta pelo portador do cartão junto ao emissor, entra no mesmo fluxo, com uma diferença: quem decide quanto sai de cada carteira costuma ser o gateway, não você. Nesse caso, registro o que o gateway informou e comparo com o que o meu rateio teria calculado. Se divergir, a diferença vai para uma fila de conciliação para análise humana. Eu não "corrijo" silenciosamente nenhum dos dois lados.

## Como testar se o split nunca perde nem cria centavos?

Testes com exemplos fixos pegam pouco. O que pega bug de arredondamento é testar o invariante com muitas entradas aleatórias, no estilo property-based testing (gerar entradas aleatórias e verificar propriedades que precisam valer para todas). Com Pest:

```php
it('soma das partes é sempre igual ao total', function () {
    foreach (range(1, 2000) as $_) {
        $total = random_int(0, 50_000_000);
        $weights = [];
        foreach (range(1, random_int(1, 8)) as $i) {
            $weights["r{$i}"] = random_int(0, 10_000);
        }
        if (array_sum($weights) === 0) {
            continue;
        }

        $parts = Allocator::allocate($total, $weights);
        $sum = array_sum($weights);

        expect(array_sum($parts))->toBe($total);

        foreach ($parts as $key => $part) {
            $floor = intdiv($total * $weights[$key], $sum);
            expect($part)->toBeGreaterThanOrEqual($floor)
                ->toBeLessThanOrEqual($floor + 1);
        }
    }
});
```

Faço o mesmo para a sequência de estornos: gero um pagamento, aplico N estornos parciais aleatórios até zerar o saldo e verifico que `refunded_amount === amount` em todas as linhas no final.

## Conclusão: o que aplicar amanhã no seu split

Se você tem split em produção, o checklist é este:

1. Procure `float`, `round(` e `* 0.` no código que mexe com valores e troque por inteiros em centavos e basis points.
2. Centralize a divisão em uma única classe, como o `Allocator`, e proíba cálculo de parte em qualquer outro lugar.
3. Escreva a regra de quem fica com o centavo e confira se a configuração do gateway diz a mesma coisa.
4. Persista as partes no momento da cobrança e nunca recalcule a partir de porcentagens.
5. Rateie estornos pelo saldo restante, com `lockForUpdate()` e uma `CHECK` constraint no PostgreSQL.
6. Coloque um teste de invariante rodando milhares de combinações no CI.

Nada disso é sofisticado, e é justamente por isso que costuma ficar para depois. Mas centavo que some em split não gera exceção nem alerta. Ele só aparece semanas depois, quando alguém tenta fechar a conciliação.

---

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