Voltar ao blog

Construindo um SaaS do zero, parte 4: cobrança e liberação de acesso por plano

Quarta parte da série: como ligar assinatura a permissão de uso, quais estados do Stripe liberam acesso, onde checar o plano e o que fazer quando o pagamento falha.

C
Caio Braga
23 de agosto de 2026 · 6 min de leitura
Sumário do artigo
Construindo um SaaS do zero, parte 4: cobrança e liberação de acesso por plano

Na parte 3 o CRUD ficou de pé e cada usuário passou a enxergar só os próprios dados. Agora vem a etapa que separa projeto de produto: cobrar, e liberar recurso conforme o plano contratado.

A parte de checkout é a mais fácil, e é onde a maioria dos tutoriais para. O difícil vem depois: o seu app precisa saber, em qualquer momento, se aquele usuário pode ou não usar aquele recurso. Isso é uma questão de estado no seu banco, não de tela de pagamento.

O modelo mental: três fontes, uma verdade

O Stripe sabe o que foi pago. Seu banco precisa saber o que está liberado. O front nunca decide nada.

O caminho é sempre o mesmo:

O usuário paga no Checkout. O Stripe envia um webhook. Sua função grava o estado no banco. Seu app lê o banco antes de liberar qualquer coisa.

Se o app perguntar ao Stripe a cada carregamento de tela, ele fica lento e refém da disponibilidade de terceiro. Se ele confiar na resposta do navegador após o pagamento, qualquer pessoa libera o plano pago editando a URL de retorno.

A tabela de assinatura

create table assinaturas (
  user_id uuid primary key references auth.users(id),
  stripe_customer_id text not null,
  stripe_subscription_id text,
  plano text not null default 'free',
  status text not null default 'inativa',
  periodo_fim timestamptz,
  cancela_no_fim boolean default false,
  atualizado_em timestamptz default now()
);

alter table assinaturas enable row level security;

create policy "ler propria assinatura"
on assinaturas for select
using (auth.uid() = user_id);

Repare que não existe policy de insert ou update. Só a função de webhook escreve nessa tabela, usando a chave de serviço, que roda no servidor e ignora RLS. O usuário lê, nunca escreve.

Ligar o pagamento ao usuário

O Stripe não conhece o id do seu usuário. Você entrega isso na criação da sessão de checkout:

const sessao = await stripe.checkout.sessions.create({
  mode: 'subscription',
  line_items: [{ price: PRICE_ID, quantity: 1 }],
  client_reference_id: usuario.id,        // id do Supabase
  customer_email: usuario.email,
  success_url: `${SITE}/obrigado?session_id={CHECKOUT_SESSION_ID}`,
  cancel_url: `${SITE}/planos`
});

O client_reference_id volta no webhook e é a ponte entre as duas bases. Sem ele você recebe a confirmação de pagamento e não sabe de quem é.

Os eventos que importam

Evento O que fazer
checkout.session.completed Criar ou atualizar a linha de assinatura, gravar customer e subscription
customer.subscription.updated Atualizar plano, status, fim do período e flag de cancelamento
customer.subscription.deleted Marcar como cancelada e voltar o plano para free
invoice.payment_failed Marcar status como inadimplente e avisar o usuário
invoice.payment_succeeded Estender o fim do período

Dois cuidados na função que recebe esses eventos:

Verifique a assinatura do webhook com stripe.webhooks.constructEvent, usando o corpo cru da requisição. Se o framework já converteu o corpo para JSON, a verificação falha. Essa é a causa número um de webhook que não funciona.

Trate repetição. O Stripe reenvia evento quando não recebe resposta 200 rápido. Guardar o event.id numa tabela de eventos processados e ignorar repetidos evita cobrar duas vezes ou duplicar registro.

Quais status liberam acesso

O status da assinatura no Stripe não é binário. Decidir o que fazer em cada um é regra de negócio, e é melhor decidir agora que no dia do primeiro problema.

Status Significado Sugestão
trialing Em período de teste Libera tudo
active Pagando em dia Libera tudo
past_due Cobrança falhou, Stripe vai tentar de novo Libera com aviso na interface
incomplete Pagamento iniciado e não concluído Não libera
canceled Encerrada Não libera

Cortar acesso no primeiro past_due gera cancelamento de gente que só trocou de cartão. A régua de tentativas do Stripe dura dias, e uma faixa de tolerância com aviso visível converte melhor que bloqueio imediato.

O cancelamento também tem nuance. Quando o usuário cancela, o normal é cancel_at_period_end, ou seja, ele já pagou o mês e continua com acesso até o fim do período. Cortar na hora do clique é entregar menos do que foi vendido.

Onde checar o plano

No servidor, sempre. Esconder o botão no front é experiência de uso, não controle de acesso.

// roda no servidor, antes de executar a ação paga
export async function podeUsar(userId, recurso) {
  const { data } = await supabaseAdmin
    .from('assinaturas')
    .select('plano, status, periodo_fim')
    .eq('user_id', userId)
    .single();

  const ativo = ['active', 'trialing', 'past_due'].includes(data?.status);
  const plano = ativo ? data.plano : 'free';

  return LIMITES[plano][recurso];
}

const LIMITES = {
  free:  { projetos: 3,   exportar: false, api: false },
  pro:   { projetos: 50,  exportar: true,  api: false },
  time:  { projetos: 999, exportar: true,  api: true  }
};

Uma tabela de limites em um lugar só evita a pior dívida desse assunto: regra de plano espalhada em quinze arquivos, cada um com um número diferente.

Limite por quantidade se checa contando no banco na hora da criação, não guardando contador que desatualiza:

const { count } = await supabase
  .from('projetos')
  .select('id', { count: 'exact', head: true })
  .is('arquivado_em', null);

if (count >= LIMITES[plano].projetos) {
  return { erro: 'limite_do_plano', limite: LIMITES[plano].projetos };
}

Devolva um código de erro identificável. É ele que permite ao front mostrar a tela de upgrade certa em vez de um alerta genérico.

O que fazer com quem passou do limite depois de mudar de plano

Usuário no plano Pro com 40 projetos que volta para o Free tem 3 de limite e 40 criados. Apagar é inaceitável. O padrão que funciona é bloquear criação e manter leitura: ele vê tudo, não cria novo enquanto não voltar ao plano ou arquivar.

Cancelamento e troca de plano sem tela própria

O portal de cliente do Stripe resolve cancelamento, troca de cartão, upgrade e histórico de faturas sem você escrever uma tela:

const portal = await stripe.billingPortal.sessions.create({
  customer: assinatura.stripe_customer_id,
  return_url: `${SITE}/conta`
});
// redirecionar o usuário para portal.url

Cada mudança feita lá dispara customer.subscription.updated no seu webhook, e o seu banco acompanha sozinho.

Testar antes de abrir

Use a CLI do Stripe para receber eventos reais em ambiente local:

stripe listen --forward-to localhost:54321/functions/v1/stripe-webhook
stripe trigger checkout.session.completed

Percorra os cinco caminhos: assinatura nova, renovação, falha de pagamento, cancelamento agendado e cancelamento efetivado. Cada um deles precisa deixar a tabela de assinatura num estado que a função podeUsar entenda.

Erros comuns

Erro Consequência Correção
Liberar plano pela URL de retorno do checkout Acesso pago de graça Só o webhook escreve o estado
Corpo já convertido em JSON na verificação Webhook rejeitado Usar corpo cru em constructEvent
Ignorar event.id Evento processado duas vezes Tabela de eventos processados
Checar plano só no front Chamada direta à API contorna o limite Checagem no servidor
Cortar acesso no past_due Cancelamento de quem só trocou de cartão Tolerância com aviso

O que vem na parte 5

Cobrança funcionando, acesso controlado, resta a parte que ninguém treina antes de precisar: colocar no ar. Na parte 5 a série trata de domínio, variáveis de ambiente, configuração de produção e o checklist do dia do lançamento.

Tags
#saas#stripe#assinatura#monetização#jornada saas
● Não perca essa chance

Não perca a próxima edição.

Toda quinta, 9h. Direto na sua caixa.

  • Ferramentas que economizam horas do seu trabalho
  • Agentes e automações que funcionam
  • Bastidores do que estamos construindo

100% gratuito. Cancele quando quiser.

Compartilhar