surfaai · parte 7 de 7

PIX, boleto e cartão: a partir de quanto cada um fica mais barato

07 de outubro de 20269 min de leituraCesar Eduardo Sturmer

Quando você monta um checkout no Brasil, a pergunta "quanto custa processar isso" tem uma resposta diferente para cada meio de pagamento. E quase toda discussão sobre isso para no lugar errado: "PIX é barato, boleto é barato, cartão é caro".

Não é falso. É só inútil, porque as três afirmações dependem do valor da venda, e o ponto onde elas trocam de ordem é calculável. Este artigo é sobre esses pontos.

O artigo 3 desta série já gastou a matemática do gross-up — a conta circular de quando você repassa a taxa ao pagador. Aqui é outra coisa: os números absolutos, de onde eles saem, e as três armadilhas que eu encontrei no caminho.

Os números

Estes são os valores no código do surfaai, referência Stripe Brasil:

meio percentual fixo
Cartão doméstico 3,99% R$ 0,39
Cartão internacional 5,99% R$ 0,39
PIX 1,19% —
Boleto — R$ 3,45

Repare na forma, antes dos números: PIX é só percentual, boleto é só fixo, cartão é os dois. As três formas se comportam de modo completamente diferente conforme o ticket, e é daí que vem tudo que segue.

O custo real por ticket

A mesma tabela, aplicada. A coluna que importa é a porcentagem efetiva, não o valor:

venda PIX boleto cartão
R$ 50 R$ 0,60 — 1,20% R$ 3,45 — 6,90% R$ 2,39 — 4,78%
R$ 80 R$ 0,95 — 1,19% R$ 3,45 — 4,31% R$ 3,58 — 4,47%
R$ 100 R$ 1,19 — 1,19% R$ 3,45 — 3,45% R$ 4,38 — 4,38%
R$ 290 R$ 3,45 — 1,19% R$ 3,45 — 1,19% R$ 11,96 — 4,12%
R$ 500 R$ 5,95 — 1,19% R$ 3,45 — 0,69% R$ 20,34 — 4,07%
R$ 1.000 R$ 11,90 — 1,19% R$ 3,45 — 0,34% R$ 40,29 — 4,03%

Três leituras:

O PIX é uma linha reta. 1,19% em qualquer valor. É o único meio cujo custo relativo não muda — o que o torna o único previsível, e o motivo de ele ser o default sensato quando você não sabe o perfil do seu ticket.

O boleto é uma curva que começa péssima. Num ticket de R$ 50, aquele R$ 3,45 fixo é 6,90% — quase o dobro do cartão. O boleto só é "barato" quando o ticket carrega a taxa fixa.

O cartão converge, nunca cai. Começa em 4,78% e desce assintoticamente para 3,99%, porque o fixo de R$ 0,39 dilui. Nunca fica competitivo, e isso é o esperado: você está pagando por crédito e por disputa.

Os dois pontos de cruzamento

Daí saem dois números que valem mais que a tabela.

Boleto passa a ser mais barato que PIX em R$ 289,92. Igualando as duas fórmulas:

0,0119 × b = 3,45
b = 3,45 / 0,0119 = R$ 289,92

Abaixo disso, PIX ganha. Acima, boleto — e a diferença cresce sem limite, porque o boleto não cresce. Em R$ 1.000 o boleto custa R$ 3,45 contra R$ 11,90 do PIX.

Boleto passa a ser mais barato que cartão em R$ 76,69:

0,0399 × b + 0,39 = 3,45
b = 3,06 / 0,0399 = R$ 76,69

Abaixo de R$ 76,69, o boleto é o mais caro dos três. Essa é a parte contraintuitiva: existe uma faixa de ticket baixo onde o meio que todo mundo chama de barato é o pior negócio da sua operação.

E cartão nunca vence PIX. Igualando, o b sai negativo — não existe valor em que 3,99% + R$ 0,39 fique abaixo de 1,19%.

Armadilha 1: o cap que não é cap

No código, o PIX tem um teto de taxa:

pix: {
  percentageFee: 1.19,
  fixedFee: 0,
  capAmount: 999.0, // Stripe não menciona cap — valor alto = sem efeito
},

Aquele 999.0 não é um limite da Stripe. É um "sem limite" escrito como número, porque a estrutura do código exige um capAmount e a Stripe não publica nenhum para PIX.

Faz a conta de quando ele morderia: 999 / 0,0119 = R$ 83.949,58. Nenhuma aula de surfe custa oitenta e quatro mil reais, então o campo nunca age.

Funciona, e eu manteria. Mas é uma mentira codificada, do tipo que depende de uma premissa não escrita — "nosso ticket nunca chega lá". No dia que alguém vender um pacote corporativo de R$ 90 mil, a taxa trava em R$ 999 e a plataforma come a diferença, e vai levar um tempo até alguém entender por quê. Um capAmount: null com o if tratando a ausência seria honesto. O comentário ao lado é o que salva — e é por isso que ele está lá.

Armadilha 2: parcelamento não custa percentual a mais

Essa eu errei por meses. O campo existe:

installmentExtraPercentPerMonth: 0,

E ele já valeu 0,50 — meio ponto percentual por parcela adicional, que é como funciona na maioria das adquirentes brasileiras. Uma migration corrigiu, e o comentário dela é a explicação inteira:

-- Stripe Brasil não cobra taxa extra por parcela.
-- O parcelamento é liquidado pelo banco/emissor — o merchant recebe o valor cheio de uma vez.
UPDATE stripe_fee_config SET card_installment_extra_pct = 0 WHERE id = 1;

O modelo é diferente do que a intuição brasileira espera. No parcelamento sem juros da Stripe, quem financia é o emissor do cartão, que cobra do portador. O merchant recebe à vista, com a taxa de cartão normal. Não há percentual por parcela porque não há crédito concedido por você.

Eu estava cobrando 0,5% por parcela de quem parcelava em 12x — seis pontos percentuais de taxa inventada — por ter assumido que a Stripe funcionava como as adquirentes que eu conhecia. O campo continua lá, zerado, porque é um gancho legítimo: se mudar, muda no banco.

Armadilha 3: taxa fixa torna o gross-up inútil

Uma consequência bonita da forma de cada taxa. A fórmula do gross-up do artigo 3:

taxa = (base × p + f) / (1 − p)

Aplique no boleto, onde p = 0 e f = 3,45:

taxa = (base × 0 + 3,45) / (1 − 0) = 3,45

O gross-up é idempotente para taxa puramente fixa. Faz sentido: a circularidade existia porque o percentual incidia sobre um total que já continha a taxa. Sem percentual, não há circularidade — somar R$ 3,45 não muda quanto é R$ 3,45.

No PIX, que é só percentual, o efeito é mínimo: num ticket de R$ 100, a taxa vai de R$ 1,19 para R$ 1,20. Um centavo. No cartão, que tem os dois, é onde o gross-up ganha o salário: de R$ 4,38 para R$ 4,56.

A regra geral: o gross-up importa proporcionalmente ao percentual. Quanto mais a taxa é fixa, menos ele faz — mas você aplica a mesma função nos três, porque a fórmula já trata o caso degenerado sozinha. Não precisa de if por método.

Onde esses números moram — e o problema que eu achei aí

As taxas não vêm da API da Stripe. O checkout precisa do número antes de criar a cobrança, e uma chamada externa para exibir um valor coloca latência e um ponto de falha no exato momento em que a pessoa decide se compra.

Então elas vivem numa tabela, stripe_fee_config, com um singleton (id = 1), carregadas por loadStripeFees(). Taxa de gateway muda em escala de meses; um UPDATE no painel resolve sem deploy. O valor no código é o fallback de quando o banco não responde:

export async function loadStripeFees(): Promise<StripeFeesConfig> {
  try {
    const { data } = await supabase
      .from('stripe_fee_config')
      .select('*')
      .eq('id', 1)
      .single();

    if (!data) return STRIPE_BR_FEES;
    // ... mapeia as colunas
  } catch {
    return STRIPE_BR_FEES;
  }
}

O desenho está certo. O problema é o que eu encontrei escrevendo este artigo: os DEFAULT da tabela são valores antigos, e discordam do fallback.

campo DEFAULT na migration fallback no código
pix_pct / pix_cap 1.00 / 15.00 1.19 / 999
boleto_pct / boleto_fixed 1.50 / 0.75 0 / 3.45
card_installment_extra_pct 0.50 0

E o seed foi INSERT INTO stripe_fee_config (id) VALUES (1) — sem valores, pegando todos os defaults. Só o campo de parcelamento foi corrigido depois, pela migration da armadilha 2. PIX e boleto não.

Como loadStripeFees() prefere o banco e só cai no fallback em erro, a linha do banco é a fonte de verdade — e ela foi semeada com os números errados. O código documenta uma taxa de PIX de 1,19% sem teto; a tabela, se nunca foi editada, diz 1,00% com teto de R$ 15. Todas as contas deste artigo saem do fallback.

Não cobrei ninguém errado: o surfaai lançou sem taxa, então não havia taxa a errar. O estrago é potencial, e vira real no dia que a cobrança ligar com a linha velha no banco.

O que isso ensina sobre o padrão: um DEFAULT de coluna é código, e envelhece como código — só que não aparece em nenhum diff depois do dia que foi escrito. O fallback no .ts foi revisado, atualizado e lido por mim várias vezes. O DEFAULT na migration foi escrito uma vez, em março, e nunca mais olhado — enquanto era ele, não o fallback, que decidia.

Se eu refizesse: ou o seed grava valores explícitos e a coluna não tem DEFAULT nenhum, ou existe um teste que compara a linha do banco com STRIPE_BR_FEES e grita quando divergem sem alguém ter mandado divergir. Ter duas fontes de verdade é aceitável. Não saber qual está valendo, não.

A regra

Taxa de pagamento não é um número, é uma função do ticket. "Qual meio é mais barato" não tem resposta sem o valor da venda, e os pontos de virada — R$ 76,69 e R$ 289,92 — são onde a resposta muda.

Isso tem consequência de produto, não só de engenharia: o meio mais conveniente na interface raramente é o mais barato na operação, e num ticket baixo o boleto é o pior dos três. Vale desenhar o checkout mostrando isso, em vez de empurrar cartão porque é o caminho de menor atrito.

E vale conferir, agora, qual linha o seu banco está realmente servindo.

← Todos os artigos