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:
- Coluna
bigintem centavos no PostgreSQL, nuncadecimalconvertido para float na aplicação. - Porcentagens em basis points. Basis point é um centésimo de ponto percentual, então 15% vira o inteiro
1500de um total de10000. - Divisão inteira explícita com
intdiv()e resto com%. Semround()espalhado pelo código. - 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:
- Procure
float,round(e* 0.no código que mexe com valores e troque por inteiros em centavos e basis points. - Centralize a divisão em uma única classe, como o
Allocator, e proíba cálculo de parte em qualquer outro lugar. - Escreva a regra de quem fica com o centavo e confira se a configuração do gateway diz a mesma coisa.
- Persista as partes no momento da cobrança e nunca recalcule a partir de porcentagens.
- Rateie estornos pelo saldo restante, com
lockForUpdate()e umaCHECKconstraint no PostgreSQL. - 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.