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_centscom 4,99 por padrão, editável a partir da conta. - Limite de gasto por chave de API.
spend_limit_centsmais 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.
