surfaai · parte 3 de 8

Destination charges na prática: split, estorno e a matemática das taxas

01 de outubro de 20265 min de leituraCesar Eduardo Sturmer

O artigo anterior foi sobre a decisão: por que deleguei a infraestrutura financeira à Stripe em vez de construir o split na mão. Este é sobre como isso funciona por dentro — incluindo uma conta que quase todo mundo erra na primeira tentativa.

Onde a cobrança acontece

O Stripe Connect tem dois padrões principais de split. A diferença entre eles é onde a cobrança nasce.

Com separate charges and transfers, você cobra na conta da plataforma e depois cria um Transfer separado para o vendedor. São duas operações, em dois momentos, que você orquestra.

Com destination charges, a cobrança já nasce sabendo para onde parte dela vai:

const paymentIntent = await stripe.paymentIntents.create({
  amount: calculation.amountInCents,
  currency: "brl",
  payment_method_types: [billingType === "card" ? "card" : billingType],
  application_fee_amount: calculation.applicationFeeAmount,
  transfer_data: {
    destination: stripeAccountId,
  },
  description,
  metadata: { serviceId, providerId /* ... */ },
});

Uma operação. A Stripe cobra do aluno, retém application_fee_amount para a plataforma e transfere o líquido para a conta Connect do provider assim que a cobrança é capturada.

Três consequências que importam:

  • O aluno vê uma cobrança só, no nome da plataforma. Um lançamento no extrato, não dois.
  • O provider recebe automaticamente, sem nenhuma rotina de repasse minha.
  • Eu nunca fico com o dinheiro dele em custódia.

O detalhe que me fez escolher: estorno

Essa é a parte pouco comentada, e foi decisiva.

Quando você estorna uma cobrança feita com destination charge, a application_fee correspondente é revertida automaticamente, na proporção do estorno. A Stripe ajusta os dois lados numa operação só.

Parece um detalhe contábil, mas olhe o que ele elimina. O surfaai tem cancelamento com direito de arrependimento: dentro de sete dias, o aluno recebe estorno real, proporcional aos créditos que não usou. Com split manual, eu teria que escrever a lógica de "devolver minha taxa também", lidar com o caso de o provider já ter sacado o valor, e reconciliar tudo isso.

Com destination charges, o estorno proporcional é uma chamada:

await stripe.refunds.create({
  payment_intent: payment.stripe_payment_intent_id,
  amount: toCents(valorProporcional),
});

E a taxa volta sozinha, na proporção certa. Zero código meu para a parte financeira do cancelamento.

As taxas não vêm da API

Para mostrar ao aluno quanto custa cada meio de pagamento no checkout, eu preciso saber a taxa antes de criar a cobrança. A tentação é consultar a Stripe. Eu não consulto.

As taxas vivem numa tabela do banco, carregadas por loadStripeFees(), com um fallback hardcoded no código:

export const STRIPE_BR_FEES: StripeFeesConfig = {
  card: {
    domestic:      { percentageFee: 3.99, fixedFee: 0.39 },
    international: { percentageFee: 5.99, fixedFee: 0.39 },
    installmentExtraPercentPerMonth: 0,
  },
  pix:    { percentageFee: 1.19, fixedFee: 0, capAmount: 999.0 },
  boleto: { percentageFee: 0,    fixedFee: 3.45 },
};

Duas decisões empilhadas aqui.

Por que não a API: o checkout precisa ser instantâneo. Uma chamada externa só para exibir um número coloca latência e um ponto de falha num lugar onde o usuário está decidindo se compra. Taxas de gateway mudam em escala de meses, não de segundos.

Por que no banco, e não só no código: quando a Stripe reajusta uma taxa, eu quero corrigir pelo painel administrativo, não abrir um pull request e esperar deploy. O valor hardcoded é a rede de segurança para quando o banco não responde.

A conta que quase todo mundo erra

Aqui está a parte interessante. O surfaai permite configurar quem paga a taxa do gateway: a plataforma, o provider ou o aluno. Quando a taxa é repassada ao aluno, aparece um problema circular.

A intuição diz: calcule a taxa sobre o preço e some.

Com um serviço de R$ 100 e cartão a 3,99% + R$ 0,39:

taxa = 100 × 0,0399 + 0,39 = R$ 4,38
total cobrado = R$ 104,38

Está errado. A Stripe não cobra a taxa sobre os R$ 100 — ela cobra sobre o valor da transação, que agora é R$ 104,38:

taxa real = 104,38 × 0,0399 + 0,39 = R$ 4,56

Faltam R$ 0,18. Pouco numa venda, relevante em milhares delas, e sempre saindo do seu bolso.

O valor correto exige resolver a circularidade. Chamando o preço de base, a taxa percentual de p e a fixa de f:

total = base + taxa
taxa  = total × p + f

Substituindo e isolando:

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

E a taxa, que é o que eu preciso somar:

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

Que é exatamente o que está no código:

export function calculateStripeFeeGrossUp(/* ... */): number {
  const { percentageFee, fixedFee } = getStripeFee(
    billingType, installments, isInternational, fees,
  );
  const pct = percentageFee / 100;

  // PIX tem cap — verificar se o gross-up também atingiria o cap
  if (billingType === "pix") {
    const fee = round2((pct * baseAmount + fixedFee) / (1 - pct));
    return fee > fees.pix.capAmount ? fees.pix.capAmount : fee;
  }
  // ...
}

Conferindo com os números de antes:

taxa = (100 × 0,0399 + 0,39) / (1 − 0,0399)
     = 4,38 / 0,9601
     = R$ 4,56

total = R$ 104,56
verificação: 104,56 × 0,0399 + 0,39 = R$ 4,56 ✓

Fecha. Esse padrão tem nome — gross-up — e aparece em qualquer lugar onde um valor percentual é calculado sobre um total que o inclui: imposto retido, comissão sobre comissão, taxa repassada.

Repare também no tratamento do cap do PIX. Um método com teto de taxa precisa verificar o limite depois do gross-up, não antes: a fórmula pode empurrar o valor acima do teto, e aí o teto é que vale.

O que eu levei disso

Destination charges substituiu, com duas propriedades de um objeto, o que seria um módulo inteiro de custódia e reconciliação. O estorno automático da taxa sozinho já pagaria a escolha.

Mas a lição que ficou é outra: delegar a infraestrutura não te isenta de entender a mecânica. O gross-up não é problema da Stripe, é meu. Se eu tivesse tratado o split como caixa-preta, estaria perdendo centavos em cada transação — e centavos em volume são a diferença entre margem e prejuízo.

No próximo artigo, um bug de verdade: como um aluno apertando F5 duas vezes gerou crédito dobrado, e o que isso me ensinou sobre onde o dinheiro pode ser criado.

← Todos os artigos