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.

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:

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:

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:

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.

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:

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.