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.
Sumário do artigo
- O modelo mental: três fontes, uma verdade
- A tabela de assinatura
- Ligar o pagamento ao usuário
- Os eventos que importam
- Quais status liberam acesso
- Onde checar o plano
- O que fazer com quem passou do limite depois de mudar de plano
- Cancelamento e troca de plano sem tela própria
- Testar antes de abrir
- Erros comuns
- O que vem na parte 5

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.
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