Para lidar com dinheiro no Laravel sem erro de centavo, guarde valores como inteiro em centavos (bigint no PostgreSQL) e embrulhe esse inteiro em um value object Money imutável, exposto por um cast customizado do Eloquent. Toda conta (taxa, split e parcelamento) é feita em aritmética inteira, com uma regra explícita de arredondamento. Na divisão de valores entre várias partes, use o método do maior resto, que garante que a soma das partes é sempre igual ao total.

Parece detalhe, mas é o tipo de bug que não aparece no teste com R$ 100,00 e aparece na conciliação do fim do mês: um centavo a mais no repasse de um recebedor, um centavo a menos na soma das parcelas, um relatório que não bate com o extrato do adquirente. Em fintech, esse centavo vira ticket de suporte e, pior, vira diferença no ledger. Vou mostrar como estruturo isso nos meus projetos em Laravel.

Por que float quebra conta de dinheiro em PHP?

float em PHP é ponto flutuante IEEE 754 de 64 bits: ele representa números em base 2, e a maioria das frações decimais (como 0,10 ou 19,99) não tem representação exata em base 2. O resultado é sempre uma aproximação.

var_dump(0.1 + 0.2 === 0.3);       // bool(false)
var_dump((int) (19.99 * 100));     // int(1998)
var_dump(floor(0.29 * 100));       // float(28)

O segundo caso é o clássico: alguém recebe 19.99 do front ou de um gateway, multiplica por 100 e faz cast para int. O cast trunca, e 19.99 * 100 vale 1998.9999999999998. Sumiu um centavo e nenhum teste com valor redondo pega isso.

Usar bcmath com strings resolve a precisão, mas espalha bcadd, bcmul e escala de casas decimais pelo código inteiro. Funciona, mas é fácil esquecer a escala em um lugar e truncar sem perceber.

Centavos em inteiro ou DECIMAL no PostgreSQL?

As duas opções são corretas no banco. A diferença está no que acontece quando o valor chega ao PHP.

Critério bigint em centavos numeric(15,2)
Precisão no banco Exata Exata
Tipo que chega no PHP via PDO int string
Aritmética no PHP Nativa, inteira Exige bcmath ou conversão
Leitura em SQL cru Precisa dividir por 100 Legível direto
Compatível com Stripe e Pagar.me Direto (já usam centavos) Precisa converter
Frações de centavo Não representa Representa até a escala definida

Eu fico com bigint em centavos para valores monetários e sufixo _cents no nome da coluna (amount_cents, fee_cents). O sufixo parece redundante até o dia em que alguém roda um SELECT no console de produção e lê 1999 como mil novecentos e noventa e nove reais.

Para taxas percentuais, como o MDR (a taxa que o adquirente cobra sobre cada venda no cartão), uso inteiro em basis points: 1 basis point é 0,01%, então 2,39% vira 239. Mesma ideia: nada de float, nem na taxa.

Schema::create('sales', function (Blueprint $table) {
    $table->id();
    $table->bigInteger('amount_cents');
    $table->bigInteger('fee_cents');
    $table->integer('mdr_bps');
    $table->timestamps();
});

Como criar um value object Money imutável?

Value object é um objeto definido pelo valor que carrega, não por identidade, e que não muda depois de criado. Toda operação devolve uma instância nova. Isso evita o bug de alterar um valor que outro trecho de código ainda estava usando.

namespace App\Support;

use InvalidArgumentException;
use JsonSerializable;

final class Money implements JsonSerializable
{
    private function __construct(public readonly int $cents) {}

    public static function cents(int $cents): self
    {
        return new self($cents);
    }

    public static function zero(): self
    {
        return new self(0);
    }

    public static function fromDecimal(string $value): self
    {
        if (! preg_match('/^(-)?(\d+)(?:[.,](\d{1,2}))?$/', trim($value), $m)) {
            throw new InvalidArgumentException("Valor monetário inválido: {$value}");
        }

        $cents = (int) $m[2] * 100 + (int) str_pad($m[3] ?? '', 2, '0');

        return new self($m[1] === '-' ? -$cents : $cents);
    }

    public function add(self $other): self
    {
        return new self($this->cents + $other->cents);
    }

    public function subtract(self $other): self
    {
        return new self($this->cents - $other->cents);
    }

    public function equals(self $other): bool
    {
        return $this->cents === $other->cents;
    }

    public function isNegative(): bool
    {
        return $this->cents < 0;
    }

    public function jsonSerialize(): int
    {
        return $this->cents;
    }
}

O fromDecimal recebe string de propósito e parseia sem passar por float. Ele rejeita separador de milhar (1.234,56) intencionalmente: se esse formato chegou no back-end, o problema está em quem mandou, e prefiro uma exceção a um palpite.

Trabalho só com real na maioria dos produtos, então o Money não carrega moeda. Se o seu caso tem várias moedas, não reinvente: brick/money e moneyphp/money são bibliotecas maduras, tratam moeda, escala e arredondamento. O trade-off é peso e API mais verbosa. Para um sistema monomoeda, umas 100 linhas próprias que eu entendo por completo me servem melhor.

Como fazer um cast customizado de Money no Eloquent?

Cast customizado é uma classe que implementa CastsAttributes e diz ao Eloquent como transformar o valor da coluna em objeto na leitura, e de volta em valor primitivo na escrita.

namespace App\Casts;

use App\Support\Money;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;

final class MoneyCast implements CastsAttributes
{
    public function get(Model $model, string $key, mixed $value, array $attributes): ?Money
    {
        return $value === null ? null : Money::cents((int) $value);
    }

    public function set(Model $model, string $key, mixed $value, array $attributes): ?int
    {
        if ($value === null) {
            return null;
        }

        if (! $value instanceof Money) {
            throw new InvalidArgumentException(
                "{$key} precisa ser Money, recebeu " . get_debug_type($value)
            );
        }

        return $value->cents;
    }
}
class Sale extends Model
{
    protected function casts(): array
    {
        return [
            'amount_cents' => MoneyCast::class,
            'fee_cents' => MoneyCast::class,
        ];
    }
}

A decisão importante está no set: ele recusa int, float e string. Isso é chato nas primeiras semanas, porque todo $sale->amount_cents = 1999 quebra. E é exatamente esse o objetivo. Quem escreve o código precisa decidir conscientemente se aquele número é centavo ou real, chamando Money::cents() ou Money::fromDecimal(). O cast vira uma barreira de tipo na fronteira com o banco.

Um detalhe que pega muita gente: agregações não passam pelo cast. Sale::sum('amount_cents') no PostgreSQL devolve numeric (a soma de bigint sobe de tipo), que chega ao PHP como string. Embrulhe na hora: Money::cents((int) Sale::sum('amount_cents')).

Como calcular taxa percentual sem erro de arredondamento?

Taxa percentual quase nunca dá centavo inteiro. Uma venda de R$ 10,00 com MDR de 2,39% dá R$ 0,239 de taxa. Alguém vai ter que arredondar, e a regra precisa estar escrita no código, não implícita no comportamento de uma função.

public function percentage(int $basisPoints): self
{
    $product = $this->cents * $basisPoints;
    $quotient = intdiv($product, 10_000);
    $remainder = $product % 10_000;

    // half-up, afastando do zero
    if (abs($remainder) * 2 >= 10_000) {
        $quotient += $product <=> 0;
    }

    return new self($quotient);
}

Money::cents(1000)->percentage(239) dá 24 centavos. Tudo inteiro, sem float em nenhum passo. Em int de 64 bits, cents * bps só estoura com valores na casa de quatrilhões de reais, então não me preocupo.

O trade-off aqui não é técnico, é de negócio: arredondar a taxa para cima favorece a plataforma, para baixo favorece o lojista. Half-up é o padrão mais defensável, mas o ponto é escolher, documentar e aplicar em um lugar só. O valor cobrado pelo adquirente também vem arredondado por regra dele; a conciliação precisa comparar com o que ele reporta, não com o que você recalculou. Esse cuidado de onde cada centavo é registrado é o mesmo que aparece quando você modela o saldo com um ledger de partidas dobradas: taxa é um lançamento próprio, não uma subtração escondida no valor líquido.

Como dividir um valor no split sem perder ou sobrar centavo?

Split de pagamento é a divisão de uma mesma transação entre vários recebedores, como lojista e parceiro. O erro mais comum é calcular cada parte como percentual do total e arredondar cada uma separadamente.

Veja: um líquido de R$ 97,61 dividido 50/50. Cada metade dá 4880,5 centavos. Arredondando cada uma com half-up, as duas viram 4881, e a soma dá 9762. O sistema acabou de criar um centavo do nada. Com três partes iguais de R$ 1,00, o efeito é o inverso: três vezes 33 dá 99 e some um centavo.

A solução é o método do maior resto (largest remainder):

  1. Calcule a parte inteira de cada recebedor com divisão inteira (arredondando sempre para baixo).
  2. Some as partes e veja quantos centavos sobraram em relação ao total.
  3. Ordene os recebedores pelo resto da divisão, do maior para o menor.
  4. Distribua um centavo para cada um, nessa ordem, até a sobra zerar.
/**
 * @param  list<int>  $ratios
 * @return list<Money>
 */
public function allocate(array $ratios): array
{
    if ($this->cents < 0) {
        throw new InvalidArgumentException('allocate não aceita valor negativo');
    }

    $total = array_sum($ratios);

    if ($ratios === [] || $total <= 0 || min($ratios) < 0) {
        throw new InvalidArgumentException('Proporções inválidas');
    }

    $shares = [];
    $remainders = [];

    foreach ($ratios as $i => $ratio) {
        $shares[$i] = intdiv($this->cents * $ratio, $total);
        $remainders[$i] = ($this->cents * $ratio) % $total;
    }

    $leftover = $this->cents - array_sum($shares);

    // arsort é estável no PHP 8: empate mantém a ordem original
    arsort($remainders);

    foreach (array_keys($remainders) as $i) {
        if ($leftover === 0) {
            break;
        }
        $shares[$i]++;
        $leftover--;
    }

    return array_map(fn (int $c) => new self($c), $shares);
}

Com isso, os 9761 centavos divididos 50/50 viram 4881 e 4880. A soma sempre fecha, por construção. A ordem dos recebedores importa no desempate, então é parte da regra: eu coloco o lojista primeiro, o que faz o centavo de diferença ir para ele.

O fluxo completo de uma venda fica assim:

$amount = Money::cents(10_000);
$fee = $amount->percentage($sale->mdr_bps);   // 239
$net = $amount->subtract($fee);               // 9761

[$merchant, $partner] = $net->allocate([70, 30]);

assert($fee->add($merchant)->add($partner)->equals($amount));

O mesmo allocate resolve parcelamento: Money::cents(10_000)->allocate([1, 1, 1]) dá 3334, 3333 e 3333. Uma convenção comum é a primeira parcela absorver a diferença, que é justamente o que o desempate pela ordem produz. Mas confirme na documentação do adquirente ou do gateway como ele distribui, porque a sua conciliação vai comparar parcela a parcela.

Como converter valores na fronteira com o gateway de pagamento?

Cada gateway representa valor de um jeito. Stripe e Pagar.me trabalham com inteiro na menor unidade da moeda, ou seja, centavos. O Asaas usa valor decimal (100.50). Essa conversão precisa ficar no adapter do gateway, nunca no domínio. É o mesmo princípio de integrar vários gateways sem amarrar o domínio a um provedor: o domínio só conhece Money.

Quando o gateway devolve JSON com número decimal, o json_decode já entrega float. Nesse ponto específico, com no máximo duas casas, round() antes do cast é seguro, porque corrige o 1998.9999.... O erro é usar (int) direto, que trunca.

// saída para o gateway que usa decimal
$payload['value'] = number_format($money->cents / 100, 2, '.', '');

// entrada vinda do gateway que usa decimal
$money = Money::cents((int) round($response['value'] * 100));

No sentido inverso, se o gateway aceitar string decimal, mande string formatada com number_format. E no seu próprio front-end, defina o contrato da API como inteiro em centavos:

public function rules(): array
{
    return [
        'amount_cents' => ['required', 'integer', 'min:1', 'max:100000000'],
    ];
}

Isso empurra a conversão para quem tem o campo de texto com máscara, e o back-end nunca precisa adivinhar se 19.9 era R$ 19,90 ou um erro de digitação.

Como formatar Money em reais para exibir?

Formatação é a única hora em que dividir por 100 em float é aceitável, porque o resultado não volta para nenhuma conta. O Laravel tem Number::currency, que usa a extensão intl:

use Illuminate\Support\Number;

public function format(): string
{
    return Number::currency($this->cents / 100, in: 'BRL', locale: 'pt_BR');
}

Saída: R$ 19,99. Atenção nos testes de snapshot: o espaço entre R$ e o número é um espaço não separável (U+00A0), não um espaço comum, e o assertSame com string digitada à mão falha sem motivo aparente.

Como testar que nenhum centavo some?

Teste com exemplos fixos é necessário, mas o que me dá confiança é um teste de propriedade: para qualquer total e qualquer conjunto de proporções, a soma das partes é igual ao total.

it('nunca cria nem perde centavo no allocate', function () {
    foreach (range(1, 1_000) as $_) {
        $total = Money::cents(random_int(0, 10_000_000));
        $ratios = array_map(fn () => random_int(1, 100), range(1, random_int(1, 6)));

        $sum = array_sum(array_map(
            fn (Money $m) => $m->cents,
            $total->allocate($ratios),
        ));

        expect($sum)->toBe($total->cents);
    }
});

it('converte decimal sem passar por float', function () {
    expect(Money::fromDecimal('19.99')->cents)->toBe(1999)
        ->and(Money::fromDecimal('0,29')->cents)->toBe(29)
        ->and(Money::fromDecimal('7.5')->cents)->toBe(750);
});

Rodar mil combinações aleatórias leva milissegundos e pega exatamente o tipo de caso de borda que ninguém lembra de escrever. Se você usa Pest, dá para trocar o laço por um dataset gerado; o princípio é o mesmo.

No code review, a regra que sigo é simples: qualquer float, / 100 ou * 100 fora da classe Money e dos adapters de gateway é motivo para pedir mudança. Esse item está no checklist que uso para revisar PRs que mexem com dinheiro, junto com idempotência e transações.

O que eu faria hoje em um projeto Laravel novo

  1. Colunas monetárias como bigint com sufixo _cents; taxas como integer em basis points.
  2. Um Money imutável com percentage() e allocate(), e a regra de arredondamento comentada no próprio método.
  3. MoneyCast em todos os models, recusando primitivos no set.
  4. Conversão de e para decimal só nos adapters de gateway, com round() e nunca (int) direto.
  5. API pública recebendo e devolvendo centavos inteiros.
  6. Teste de propriedade garantindo que split e parcelamento sempre fecham com o total.

Se o projeto já existe e usa float, não tente migrar tudo de uma vez. Comece pelo Money e pelo allocate, aplique primeiro no split e no parcelamento, que são onde o centavo some de verdade, e vá trocando as colunas uma a uma. Cada coluna migrada para _cents com cast é um lugar a menos onde a conciliação do fim do mês pode te surpreender.