← Voltar ao blog
EngenhariaBuild in PublicPreços

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

Vendemos um plano que não funcionava

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:

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 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 →
Vendemos um plano que não funcionava | Katto