02Produto5 min

Créditos, saldo, limites de gasto

Em cinco produtos montei a cobrança por uso do mesmo jeito: começo com créditos, acabo com saldo em centavos e limites por chave. Já sei em que etapa começar.

Por Salva Castells

Imagem de capa: Federico Candoni, CC BY-SA 4.0.
Neste post

Quando um produto consome IA, a primeira coisa que monto são sempre créditos. Um inteiro na tabela de perfil, subtrai-se um por geração, e vamos em frente. Fiz isso num app de transcrição, num SaaS de geração de imagens e no boilerplate com que começo projetos. Nas três vezes acabei no mesmo lugar: um saldo em centavos com decimais e limites de gasto por chave de API.

O percurso é sempre o mesmo, então já sei em que etapa convém começar.

Por que os créditos sempre parecem suficientes no início

Porque são, enquanto o custo por operação for constante. Uma imagem custa uma imagem. Um crédito grátis por dia, vinte com a assinatura, e o check é uma linha:

if (!profile || profile.subCredits < estimatedCost) {
  return NextResponse.json({ error: "Insufficient credits" }, { status: 402 });
}

Entende-se na landing sem explicar nada. E como número inteiro é confortável: não há arredondamentos, o usuário vê "restam 12" e não pergunta mais.

O primeiro aviso de que o modelo fica curto não é técnico, é de preço. Num projeto de transcrição eu cobrava por caractere e duas semanas depois do lançamento subi o preço por caractere multiplicando por seis em relação ao custo do provedor, depois de calcular quantos caracteres tem uma frase em média em inglês (uns 75) e em espanhol (uns 90), e quantas frases saíam por pacote de 2 dólares. Esse cálculo você não faz no começo. Faz quando já tem faturas do provedor e vê que a margem não era a que você imaginava.

Onde quebra assim que o custo real entra em jogo

Quebra no momento em que um produto tem mais de um modelo. Se o usuário pode escolher entre um modelo de transcrição barato e outro preciso, um crédito deixa de significar algo. Ou você cobra o caro como se fosse barato e perde dinheiro, ou cobra tudo a preço de caro e o barato não faz sentido.

E quebra de novo com duas bolsas de créditos. No SaaS de imagens adicionei créditos diários gratuitos em cima dos de assinatura e naquele mesmo dia tive que consertar o fato de não dar para gerar nada: a verificação continuava olhando só subCredits. O conserto foi somar as duas bolsas antes de comparar.

const totalAvailableCredits = (profile?.dailyCredits || 0) + (profile?.subCredits || 0);

Parece óbvio ao ler. Não é, quando a bolsa nova é adicionada num commit e a checagem mora em outro arquivo.

O que o saldo resolve e os créditos não resolviam

O saldo é um número em centavos que é descontado pelo custo real de cada operação. Isso permite misturar modelos, provedores e unidades diferentes (minutos de áudio e tokens de texto) na mesma conta.

O detalhe que eu não esperava: centavos inteiros não bastam. Uma transcrição curta com um modelo barato custa uma fração de centavo, e com integer cada chamada arredondava para zero e o saldo nunca baixava. Tive que migrar as colunas para ponto flutuante:

ALTER TABLE "usage_logs"   ALTER COLUMN "cost_cents"    SET DATA TYPE real;
ALTER TABLE "user_billing" ALTER COLUMN "balance_cents" SET DATA TYPE real;
ALTER TABLE "api_keys"     ALTER COLUMN "spend_limit_cents" SET DATA TYPE real;

Sim, dinheiro em real. Para um saldo pré-pago que só é descontado e recarregado isso me compensa: o erro de arredondamento é irrelevante comparado a perder o valor inteiro por truncamento. Se eu faturasse a posteriori usaria inteiros na menor unidade que existisse.

O saldo muda também o pricing: de "um plano" para três (4,99, 14,99 e 49,99 por mês, expressos em horas de transcrição e tokens) mais recargas avulsas de 5, 10 e 25 euros. Por dentro é tudo a mesma coisa: somar centavos.

O caso extremo: três modelos de cobrança no mesmo produto

No app de transcrição cheguei a ter três coisas ao mesmo tempo, e nem todas sobreviveram:

  • Saldo. O que resta. Se é zero ou menos, não se serve nada.
  • Limite mensal do usuário. Um campo monthly_limit_cents com 4,99 por padrão, editável a partir da conta.
  • Limite de gasto por chave de API. spend_limit_cents mais um período (daily, monthly, total), contra a soma dos logs de uso daquela chave.

O limite mensal do usuário eu apaguei. ALTER TABLE "user_billing" DROP COLUMN "monthly_limit_cents" e fora o seletor, a server action e as traduções em cinco idiomas. Não acrescentava nada: o saldo pré-pago já é o limite mensal. Um usuário que coloca 20 euros não precisa além disso dizer que não quer gastar mais de 20 euros. Era mais uma tela para explicar e mais uma condição para verificar.

O que fica é o limite por chave, porque aí existe um caso real: uma chave metida num script que sai do controle. E há um detalhe que aprendi do jeito difícil: é preciso reler a chave do banco pelo seu id antes de comparar, mesmo que você já tenha o objeto vindo da autenticação. Senão, você serve requisições contra um spendLimitCents vencido.

O guard tem que ser colocado em todos os endpoints, não só nos que gastam. Os de listar modelos e presets também o levam, devolvendo 429. Se alguém está zerado, que a API inteira diga não, não só a parte cara.

Como decido hoje no dia um

Se o produto tem uma única operação com custo fixo, créditos. Se há mais de um modelo, ou o custo depende do tamanho da entrada, saldo em centavos com decimais desde o primeiro commit. Migrar depois custa uma tarde de esquema e um mês de e-mails explicando aos usuários que seus créditos agora são euros.

E mais uma coisa, à parte do modelo: o cupom de desconto do checkout se coloca numa constante e se testa. Um dia mudei o couponId de "-100 euros" para "-100%" de manhã e desfiz à tarde. Dois commits, o mesmo arquivo, uma linha.

Os créditos são uma decisão de marketing disfarçada de decisão técnica. O saldo é o contrário.