Vendemos um plano que não funcionava
Lançámos um plano de 4,90 €/mês. A opção anual teria cobrado 58,80 € todos os meses, o plano não concedia qualquer direito e o worker ignorava o seu limite de duração. Os três encontrados comprando-o nós mesmos.
4 de outubro de 2026
Criado em 4 de outubro de 2026 · Atualizado em 4 de outubro de 2026
Um plano de 4,90 €/mês entrou em linha na nossa página de preços. A quem escolhesse a opção anual teriam sido cobrados 58,80 € todos os meses.
Não aconteceu a ninguém.
Apanhámo-lo antes de um único cliente comprar o plano anual, e não a reler o código, mas a comprar o produto nós mesmos com um cartão verdadeiro.
Esse detalhe conta. A build estava verde. O TypeScript estava contente. A API devolvia 200. Nada disso significava que o produto funcionasse.
Encontrámos três defeitos nessa manhã. O nosso primeiro cliente Solo pagante subscreveu vinte e sete minutos depois do último desses três arranjos. Meia hora no outro sentido e teria sido ele a pagar por um plano que não lhe concedia nada.
Defeito 1: o preço estava certo, o intervalo não
O nosso novo plano de entrada, Solo, custa 4,90 €/mês com faturação anual, ou 8,90 € ao mês. 4,90 € × 12 = 58,80 € uma vez por ano.
O preço Stripe por trás da opção anual tinha sido criado com um intervalo de faturação mensal.
O Stripe estava assim pronto a cobrar 58,80 € todos os meses — doze vezes o preço previsto.
A página de preços estava correta. O código usava o identificador de preço correto. O defeito vivia fora do repositório, num objeto Stripe criado através do painel.
É precisamente isso que o tornava fácil de passar ao lado.
O Stripe não permite alterar o intervalo de faturação de um preço existente: cria-se um novo preço e troca-se o identificador. Acrescentámos então uma verificação antes de criar a sessão de pagamento:
const expected = effectivePeriod === "annual" ? "year" : "month";
const price = await stripe.prices.retrieve(priceId);
if (price.recurring?.interval !== expected) {
throw new Error("PRICE_INTERVAL_MISMATCH");
}
Uma chamada à API por pagamento.
Se o Stripe e a página de preços se contradizem, recusamos vender.
O objeto Stripe defeituoso pode continuar a existir. Simplesmente já não pode atravessar o pagamento sem que o notemos.
Defeito 2: o plano existia comercialmente, não funcionalmente
O segundo defeito falhava na direção oposta: um cliente podia pagar-nos e continuar a ser tratado como gratuito.
Acrescentar um plano parece uma mudança comercial. Na prática, toca em cada lugar do produto que pergunta: «o que é que esta conta tem permissão para fazer?»
No nosso código, essa pergunta estava a ser respondida em 29 ficheiros.
Essas verificações tinham-se acumulado ao longo do tempo nas exportações, nos carregamentos, na fila de processamento, nos pontos de API, na faturação e no painel. Eram sobretudo comparações de cadeias escritas contra os planos que existiam quando aquele código foi acrescentado.
O Solo era novo.
Verificações do tipo é pro, creator ou studio? caíam portanto no ramo por omissão. E o ramo por omissão era o gratuito.
Isso significava que uma conta Solo pagante podia receber a quota gratuita, os limites de carregamento do gratuito, a marca de água e as restantes restrições do nível gratuito.
O instinto por trás desse valor por omissão não era tolo. Em segurança, falhar fechado é geralmente o que se quer.
Na faturação, porém, cria um modo de falha diferente: o cliente já pagou, e o vosso recurso mais seguro passa a ser exatamente aquilo que lhe retém o que comprou.
A correção não foi acrescentar "solo" a 29 condições. Substituímos tudo isso por um único lugar onde o comportamento de um plano é descrito:
export type Plan = "free" | "solo" | "pro" | "creator" | "studio";
export function isPaid(plan): boolean
export function hasDubbing(plan): boolean
export function hasScheduling(plan): boolean
export function videoQuota(plan, status): number
export function maxDurationMinutes(plan): number
export function maxUploadSize(plan): number
export function retentionHours(plan): number | null
Os nomes acima estão simplificados para o artigo, mas a estrutura é a que colocámos em produção.
A regra no topo desse ficheiro diz agora: um plano não existe até estar aqui.
Houve dois quase-acidentes úteis durante a correção.
Primeiro, quase usámos essa única verificação isPaid() para todas as portas, incluindo as específicas de cada funcionalidade. Isso teria dado aos clientes Solo coisas que não pagam, incluindo a dobragem com IA e o agendamento de publicações. «Pagante» e «tem direito a esta funcionalidade» não são a mesma pergunta.
Segundo, acreditámos por um momento que o agendamento não tinha qualquer verificação de plano, porque procurar "plan" na rota não devolvia nada. A porta estava dentro de uma função partilhada, a uma chamada de distância.
Um grep que volta vazio não é uma resposta.
Defeito 3: o limite que anunciámos e nunca aplicámos
A página de preços diz que o Solo aceita vídeos de origem até 60 minutos. O worker que descarrega e processa os vídeos conhecia apenas dois limites:
- 30 minutos para contas gratuitas
- 90 minutos para contas pagantes
O Solo é pagante. Logo, o Solo recebeu 90.
Este defeito fugia na direção oposta. Ninguém foi cobrado em excesso e ninguém perdeu acesso. Simplesmente dávamos mais computação do que o plano devia incluir.
Para um produto de vídeo, isso conta. A duração da origem é um dos fatores mais diretamente ligados ao custo de transcrição, renderização e largura de banda.
O worker transporta agora o mesmo teto que a aplicação:
def _max_minutes(plan: str | None) -> int:
"""Espelho dos limites de plano usados pela aplicacao.
O que a pagina de precos anuncia e o que o worker aplica
tem de ser o mesmo numero.
Um limite anunciado sem ser aplicado e margem que se vai;
aplicado sem ser anunciado e uma armadilha.
"""
Um limite que só existe na página de preços não é um limite. É uma frase numa página.
O que realmente encontrou estes defeitos
Não o verificador de tipos. Não a suite de testes. Não uma revisão de código.
O que os encontrou foi abrir o site a fingir que nunca o tínhamos visto, clicar na opção mais barata e introduzir um cartão verdadeiro.
A discrepância de faturação apareceu no próprio resumo de pagamento do Stripe. Os direitos ausentes apareceram quando a conta pagante continuou a comportar-se como gratuita. O problema de duração apareceu quando perguntámos ao worker que limite aplicaria de facto.
Continuamos a reaprender a mesma lição: uma build que passa não prova que um produto funciona.
Cada defeito vivia na folga entre dois sistemas que, isoladamente, faziam exatamente o que lhes havia sido dito:
- a página de preços e o Stripe
- o nome de um plano e os direitos que concede
- a aplicação e o worker
A maneira económica de encontrar esta família de defeitos é surpreendentemente aborrecida: usar o produto como o fará a pessoa que paga por ele.
Um epílogo sobre atribuição
O nosso primeiro subscritor Solo chegou na mesma manhã.
Durante cerca de uma hora contámos a nós mesmos uma história bonita: o Solo tinha acabado de sair, alguém encontrou-o sozinho, atingiu o limite do gratuito e converteu.
O registo de eventos contava algo menos arrumado.
Essa pessoa tinha encontrado o Katto através do ChatGPT, registou-se, usou muito o plano gratuito, atingiu a quota, abriu o pagamento várias vezes e foi-se embora sem pagar.
Na manhã seguinte saiu um email de recuperação de carrinho abandonado. Algumas horas depois, subscreveu.
Escolheu a opção de 8,90 € ao mês em vez de 4,90 € com faturação anual, pagando 82% mais por mês para evitar um compromisso de doze meses. A oferta de fundador que tinha clicado e abandonado no dia anterior era de 6 €.
Tínhamos também suposto que era o limite de 60 minutos na origem que a tinha empurrado para o Solo. Não era. Aquilo em que batia repetidamente era a quota de dois vídeos por mês.
Duas suposições morreram ao mesmo tempo. A conversão não foi simplesmente orgânica — o email de recuperação provavelmente contou. E o limite que criava a maior pressão para subscrever não era o que pensávamos.
Tem exatamente a mesma forma dos três defeitos acima. De fora, era fácil contar uma história limpa. Depois olhámos para o que realmente aconteceu.
O Katto transforma vídeos longos em clips verticais. Publicamos os números que medimos, incluindo os que nos deixam mal.
Artigos relacionados
Pronto para transformar seus vídeos em clipes virais?
O Katto corta, legenda e reenquadra automaticamente seus vídeos longos em conteúdo de formato curto.
Experimente o Katto grátis →