Parcelamento do Mercado Pago sem chamar a API
Olá meus Unicórnios! 🦄✨
Aquele pedido inocente: "coloca na página do ingresso um ou até 12x de R$ 10,17, igual às lojas grandes". Meia hora, no máximo. 😅 Pois é.
O problema é que o valor da parcela não é o preço dividido por 12. Tem juros no meio, e quem manda nos juros é o Mercado Pago, não eu. O caminho óbvio é perguntar a ele — existe uma API que responde exatamente isso. Só que essa página é a mais visitada do site, e chamar uma API externa a cada visita, só para desenhar uma frase, é caro e frágil: se o Mercado Pago demora, minha página demora junto.
Então a conta passou a ser feita offline, aqui mesmo no PHP. E foi aí que apareceu o bug que dá nome a este artigo — um bug meu, de sete centavos, que ficou meses passando despercebido. 😳
🧮 O coeficiente: a conta que o Mercado Pago já fez por você
A primeira coisa que eu tentei foi bonita e errada: aplicar a fórmula de juros compostos (a Tabela Price, aquela do financiamento) com a taxa que aparece no painel. Os números quase batiam com o checkout. Quase.
Não bate porque o Mercado Pago não te entrega uma taxa mensal para você calcular em cima — ele te entrega o resultado pronto. Na resposta da API de parcelamento vem um campo chamado installment_rate, que é o acréscimo percentual sobre o total daquela opção. Ou seja: a conta difícil já foi feita. O que sobra para mim é uma multiplicação.
Transformando esse percentual em multiplicador:
coeficiente = 1 + (installment_rate / 100)
Com isso, cada quantidade de parcelas vira um número só. Multiplique o preço por ele e você tem o total com juros:
function CoeficientesMercadoPago()
{
return array(
1 => 1.0000,
2 => 1.0964,
3 => 1.1123,
4 => 1.1136,
5 => 1.1431,
6 => 1.1432,
7 => 1.1672,
8 => 1.1673,
9 => 1.1969,
10 => 1.2065,
11 => 1.2066,
12 => 1.2211,
);
}
Repare no detalhe curioso: os valores andam de dois em dois. O 3x (1,1123) e o 4x (1,1136) são quase idênticos; o 5x e o 6x também; o 7x e o 8x também. Não é erro de digitação — é assim que as faixas de taxa do Mercado Pago funcionam mesmo. Parcelar em 4x custa praticamente o mesmo que em 3x, o que é uma informação bem útil para quem está comprando.
Para pegar os seus coeficientes (eles variam por conta e por bandeira), é uma chamada só, feita uma vez, na mão:
curl "https://api.mercadopago.com/v1/payment_methods/installments?payment_method_id=visa&amount=100&access_token=SEU_TOKEN"
💸 Centavos, sempre centavos
Antes de dividir qualquer coisa, uma regra que vale para dinheiro em geral: não faça contas em float. Em ponto flutuante, 0.1 + 0.2 não dá 0.3, e esse resíduo microscópico reaparece justamente na hora de somar doze parcelas e comparar com o total.
A solução é velha e infalível: multiplique tudo por 100 logo na entrada, trabalhe com números inteiros, e só divida por 100 na hora de mostrar na tela.
// Valor zero ou negativo nao gera opcao nenhuma -- evita divisao por
// zero e um "12x de R$ 0,00" na tela.
if ($Valor <= 0) {
return $Opcoes;
}
$ValorCentavos = (int) round($Valor * 100);
Aquele if não é paranoia decorativa. Ingresso de cortesia custa R$ 0,00, e sem essa linha o comprador vê a página oferecendo "12x de R$ 0,00" com toda a seriedade do mundo. 🙈
🐛 Sete centavos que ninguém viu
Se você só for ler um pedaço deste artigo, leia este. 🙏
Tendo o total com juros, falta dividir pelo número de parcelas. E aqui eu fiz o que parecia a coisa mais responsável do mundo: arredondei para cima. Afinal, arredondar para baixo faria a loja receber menos, certo? Melhor pecar pelo excesso.
$TotalComJuros = round($ValorCentavos * $Coeficiente);
$ValorParcela = ceil($TotalComJuros / $NumeroParcelas); // 😬
Parece inofensivo. Não é. Vamos pegar R$ 100,00 em 8x, com o coeficiente 1,1673:
total com juros = 10000 x 1,1673 = 11673 centavos = R$ 116,73
parcela = ceil(11673 / 8) = 1460 centavos = R$ 14,60
o que a pagina anuncia: R$ 116,73
o que o comprador soma: 8 x R$ 14,60 = R$ 116,80
---------
R$ 0,07 a mais
Isso mesmo, sete centavos! 🤯 A página promete um total e exibe parcelas que somam outro. Ninguém vai à falência por sete centavos, mas é o tipo de coisa que um cliente atento manda print e pergunta — e você não tem resposta boa.
E o pior: o erro cresce com o número de parcelas, porque cada parcela carrega seu próprio arredondamento. Rodei os doze casos para R$ 100,00 e nove deles não fecham.
A correção é de duas linhas, e é a mesma que banco usa: divida inteiro nas primeiras parcelas e deixe a última absorver o resto.
function DividirEmParcelas($TotalCentavos, $NumeroParcelas)
{
$Primeiras = intdiv($TotalCentavos, $NumeroParcelas);
$Ultima = $TotalCentavos - ($Primeiras * ($NumeroParcelas - 1));
return array('primeiras' => $Primeiras, 'ultima' => $Ultima);
}
Agora o mesmo caso fecha exatamente:
7 parcelas de R$ 14,59 + 1 ultima de R$ 14,60 = R$ 116,73 ✓
Repare que é intdiv() e não round() nas primeiras. Usar round() ali reabriria o mesmo buraco pelo outro lado: a última parcela teria que devolver a diferença e poderia ficar menor que as outras — ou até negativa, num valor bem pequeno. Com intdiv(), a última é sempre a maior, no máximo alguns centavos acima. É por isso que aquela sua fatura sempre tem a última parcela com um centavo a mais. 😄
Para não confiar só no exemplo bonito, rodei a verificação em todos os valores de R$ 0,01 até R$ 200,00, contra as doze opções — 240 mil casos:
casos=240000 erros=0
🧾 Juntando tudo
Com as duas peças no lugar, a função principal fica curta. Ela tem dois modos, porque na prática a loja pode querer bancar os juros em algumas parcelas (o famoso "3x sem juros", onde quem paga o Mercado Pago é o lojista):
foreach (CoeficientesMercadoPago() as $n => $Coeficiente) {
$TotalCentavos = (int) round($ValorCentavos * $Coeficiente);
$Divisao = DividirEmParcelas($TotalCentavos, $n);
// Parcela abaixo do minimo: para por aqui. As proximas so
// ficariam menores ainda.
if ($n > 1 && $Divisao['primeiras'] < $MinimoCentavos) {
break;
}
$Opcoes[] = array(
'parcelas' => $n,
'valorParcela' => $Divisao['primeiras'] / 100,
'valorUltima' => $Divisao['ultima'] / 100,
'valorTotal' => $TotalCentavos / 100,
'acrescimo' => ($TotalCentavos - $ValorCentavos) / 100,
'semJuros' => ($n == 1),
);
}
Aquele break merece uma palavra, porque ele não é um continue disfarçado. Quando a parcela fica abaixo do mínimo, todas as opções seguintes serão ainda menores — não há motivo para continuar testando. E o mínimo importa de verdade: sem ele, um ingresso de R$ 10,00 aparece na tela oferecendo 12x de R$ 1,02, o que é constrangedor e nem sequer é aceito pelo cartão.
O detalhe do modo "sem juros" é que ele não usa coeficiente nenhum: é divisão pura, e o valorTotal continua igual ao preço à vista. Faz sentido — quem está pagando os juros ali é a loja, e isso não aparece para o comprador.
🖥️ A prova na tela
Aqui é onde eu deixo de acreditar em mim mesma e passo a conferir. A página de exemplo tem uma última coluna que faz uma coisa só: soma as parcelas de verdade, em centavos, e compara com o total anunciado.
// A prova: soma as parcelas de verdade, em centavos, e compara.
$SomaCentavos = (int) round($Opcao['valorParcela'] * 100) * ($Opcao['parcelas'] - 1)
+ (int) round($Opcao['valorUltima'] * 100);
$TotalCentavos = (int) round($Opcao['valorTotal'] * 100);
$Confere = ($SomaCentavos === $TotalCentavos);
É essa coluna que transforma o artigo em algo que você pode verificar em vez de acreditar:
Doze linhas, doze "sim". E o mais divertido: troque as duas linhas da DividirEmParcelas() pelo ceil() errado, recarregue, e a coluna vira NÃO em nove das doze linhas, com a diferença em centavos ao lado. O bug deste artigo acontece na sua frente em trinta segundos — e o README explica qual linha trocar.
🚦 Uma ressalva importante
Este cálculo é para exibir, não para cobrar. 🙂
Quem decide o valor da transação é o Mercado Pago, no momento da autorização — o número que vale é o que volta na resposta da API. A conta offline serve para a vitrine: aquela frase na página do produto, o resumo do carrinho, o e-mail de confirmação. Se a tabela de coeficientes envelhecer, o comprador vai pagar o valor certo de qualquer jeito; o que fica errado é a promessa que você fez antes.
Por isso o comentário em cima do array não é enfeite. É o único aviso que existe.
📦 O código completo
Está tudo no repositório, em dois arquivos: a lógica em parcelamento.php e a página de teste em publico/index.php. Sem Composer, sem dependência, sem chave de API — roda com php -S e pronto.
Por hoje é só, meus unicórnios! 🦄✨
Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟
Leia também
PHP: faturando compras no BOnline (Portugal)
Como emitir uma fatura em Portugal pela API do BOnline com PHP: NIF, IVA por linha, o total ao cêntimo e o documento que não pode ser reenviado.
PHP: faturando compras no Igest, em Portugal
Como emitir uma fatura em PHP pelo webservice SOAP do Igest: o IVA por linha, o total ao cêntimo, o NIF limpo e a fatura que não se reenvia.
Criando um Dockerfile para um site em PHP
Como empacotar um site PHP num container: a imagem oficial com Apache, as extensões que faltam, o .dockerignore que protege o .env e as camadas.