Pular para o conteúdo
KrokoPay
Voltar para o blog

Produto

Como desenhar os estados de cobrança de uma assinatura

Cobrança recorrente não cabe num booleano. É uma pequena máquina de estados, com cinco estados, alguns eventos e várias decisões de produto. Veja como modelar, o que fazer em cada estado e os erros que mais custam caro.

Escrito por Time KrokoPayPublicado em 7 min de leitura

Boa parte dos bugs de assinatura começa com uma coluna só: is_paying. No primeiro dia ela parece suficiente, porque todo cliente pagou ou não pagou. Aí uma renovação falha de madrugada, alguém pausa o plano por um mês, outra pessoa cancela mas ainda tem duas semanas pagas, e o booleano começa a mentir. Cada uma dessas situações pede um comportamento diferente no seu produto, e uma flag não consegue representar nenhuma delas.

É mais útil pensar na assinatura como uma máquina de estados: poucos estados, transições explícitas entre eles e um evento para cada transição. Neste artigo, vamos passar pelos estados de uma assinatura na KrokoPay, pelo que provoca cada mudança, pelo que o seu produto deve fazer em cada caso e por como espelhar tudo isso no seu próprio banco de dados.

Os cinco estados

StatusSignificadoComportamento típico do produto
trialingEm período de teste, antes do primeiro período pago.Acesso completo, com a data de fim do teste bem visível.
activeO período atual está pago e renova em current_period_end.Acesso completo. Nada a avisar, a não ser que haja um cancelamento agendado.
past_dueA última cobrança de renovação falhou.Uma decisão sua: carência com aviso claro ou acesso restrito.
pausedNenhuma renovação é cobrada até a retomada.Acesso suspenso, dados preservados e um jeito óbvio de retomar.
canceledEncerrada. Não pode ser retomada.Sem acesso pago. Um caminho para assinar de novo.

Dois campos completam o quadro. O current_period_end diz até quando o cliente já pagou. O cancel_at_period_end indica que uma assinatura active não vai renovar: o cliente pediu para sair, mas continua com o que já pagou, e o canceled_at mostra quando ela termina.

O que provoca cada transição

Toda seta da máquina tem uma causa e um evento. Algumas causas vêm da cobrança (uma renovação foi paga ou falhou); outras são pedidos seus ou do cliente (pausar, retomar, cancelar).

DeParaO que aconteceEventos
—activeO primeiro período é pago num link de pagamento, ou a assinatura é criada pela API.subscription.created, charge.paid
activeactiveUma cobrança de renovação é paga e um novo período começa.charge.paid, subscription.renewed
activepast_dueUma cobrança de renovação falha.charge.failed, subscription.past_due
trialing / active / past_duepausedVocê pausa a assinatura.subscription.paused
pausedactiveVocê retoma. Se o período pago já acabou, um novo é cobrado na hora.subscription.resumed e, quando cobrado, subscription.renewed
qualquer um, menos canceledcanceledCancelamento imediato, ou o fim do período com cancel_at_period_end.subscription.canceled

Informação: Duas regras ainda em definição

Na proposta atual da API, a passagem do período de teste para o primeiro período pago e a política de novas tentativas depois de uma renovação que falhou ainda serão definidas. Não deixe suposições sobre isso fixas no código: faça seus handlers reagirem aos eventos que chegarem, seja qual for o momento. Detalhes em Assinaturas.

Duas transições merecem atenção. Agendar o cancelamento (cancel_at_period_end: true) não gera evento na hora do pedido; o subscription.canceled chega quando o período termina de fato. E cancelar imediatamente não devolve nada automaticamente: se a sua política é reembolsar o período não usado, faça o reembolso da última cobrança você mesmo.

O que o seu produto faz em cada estado

A cobrança diz o que aconteceu. O seu produto decide o que isso significa para o cliente. Deixe essas decisões escritas, estado por estado, para que suporte, engenharia e design deem a mesma resposta.

Acesso

  • trialing e active: acesso completo até current_period_end.
  • active com cancel_at_period_end: acesso completo até a data em canceled_at. Cortar antes é punir quem pagou.
  • past_due: escolha entre uma carência com acesso completo e um modo restrito (só leitura, sem criar projetos novos). A resposta certa depende do que você vende e de quanto custa atender um cliente que talvez não pague.
  • paused: suspenda os recursos pagos, mas guarde os dados do cliente para que a retomada seja imediata.
  • canceled: retire o acesso pago. Mantenha os dados pelo tempo que a sua política de retenção definir.

Comunicação

Cada estado tem uma mensagem que o cliente precisa receber, e só uma. Em past_due, que o pagamento não passou e como resolver, com um caminho direto para pagar. Em paused, quando ele pausou e como retomar. No cancelamento agendado, a data de término e a chance de voltar atrás. Evite alertas em active: assinatura saudável não deveria chamar atenção.

Carência é uma decisão, não um número

Por quanto tempo um cliente em past_due mantém o acesso é política de produto, não algo que o sistema de cobrança deva decidir por você. Deixe isso explícito: uma constante no código, uma frase na central de ajuda, a mesma regra para todo mundo. E, seja qual for o caminho de recuperação, ele chega até você como evento: deixe o mesmo handler que estende um período normal (subscription.renewed) devolver o acesso, em vez de criar um caso especial para quem volta do past_due.

Refletindo os eventos no seu banco

Guarde o estado da assinatura do jeito que a KrokoPay informa, e não uma flag derivada dele. Todo evento de assinatura traz a assinatura completa em data.object, então o handler correto mais simples copia os campos de que você precisa e registra o evento.

Espelhe a assinatura e guarde o históricoTypeScript
// Depois de validar a assinatura do webhook e deduplicar pelo event.id.async function handleSubscriptionEvent(event: SubscriptionEvent) {  const sub = event.data.object;​  // 1. Primeiro, grave no histórico: nunca perca o que aconteceu.  await db.subscriptionEvents.insert({    eventId: event.id,    subscriptionId: sub.id,    type: event.type,    status: sub.status,    occurredAt: event.created_at,  });​  // 2. Atualize o estado atual, ignorando eventos mais antigos do que o que já temos.  await db.subscriptions.upsertIfNewer(sub.id, event.created_at, {    status: sub.status,    currentPeriodEnd: sub.current_period_end,    cancelAtPeriodEnd: sub.cancel_at_period_end,    canceledAt: sub.canceled_at,  });}​// O acesso é calculado a partir do estado, num lugar só.function hasAccess(s: StoredSubscription, now: Date, graceEndsAt: Date | null): boolean {  if (s.status === "trialing" || s.status === "active") return true;  if (s.status === "past_due") return graceEndsAt !== null && now < graceEndsAt;  return false; // paused, canceled}
  • Deduplique pelo id do evento. Entregas podem se repetir; o mesmo evt_… não pode ser aplicado duas vezes.
  • Descarte eventos atrasados. A ordem de chegada não é garantida. Compare o created_at ou consulte a assinatura com GET /v1/subscriptions/{id} na dúvida.
  • Ouça também os charge.*. Cada período é uma cobrança, então charge.paid e charge.failed trazem o comprovante e o motivo da falha por trás de cada transição.

Entrega, assinatura e ordem dos eventos estão detalhadas em Webhooks.

Erros para evitar

  1. Booleanos como is_paying ou is_active. Eles espremem cinco estados em dois e obrigam cada tela a adivinhar. Guarde o status e calcule o acesso numa função só.
  2. Sobrescrever o histórico. Se você só guarda o último status, não consegue responder "quando esse cliente entrou em atraso?" ou "quantas vezes ele já pausou?". Uma tabela de eventos só de inserção custa pouco e responde às duas.
  3. Falhas silenciosas. Uma assinatura em past_due que ninguém percebe é receita perdida e cliente confuso. Avise o cliente, mostre para o time e deixe a recuperação a um clique.
  4. Cortar o acesso no pedido de cancelamento. Cancelamento agendado ainda é período pago. Espere o subscription.canceled.
  5. Tratar pausa como cancelamento. Quem pausa pretende voltar. Guarde os dados e deixe a retomada à vista.
  6. Reagir ao navegador. Voltar do checkout não prova pagamento. Libere acesso com base nos eventos.

Passe por todos os estados antes de lançar

No ambiente de teste, dá para iniciar uma assinatura por um link de pagamento, pausar, retomar e cancelar com as chaves de teste, acompanhando os eventos que o seu endpoint receberia. Percorra cada linha da tabela de transições uma vez e confira se o produto mostra a coisa certa em todas. A página de assinaturas mostra como fica para os seus clientes.

  • Engenharia8 min de leitura

    Webhooks sem surpresas

    Reenvios, duplicatas e eventos fora de ordem não são casos raros: é isso que entrega "pelo menos uma vez" quer dizer. Como montar um consumidor que verifica, responde rápido, processa uma vez só e concilia.

  • Produto7 min de leitura

    Anatomia de um checkout que converte

    Poucos campos, um total que não muda, uma escolha de método clara, recusas explicadas em português claro e uma tela para cada desfecho. O que uma página de pagamento precisa acertar, com o nosso próprio checkout como exemplo.

Experimente no ambiente de teste

Crie uma conta para pegar suas chaves de teste, abrir o checkout hospedado e mandar um webhook assinado para você mesmo. Nada de verdade é cobrado.

As condições comerciais ainda serão anunciadas. Veja o que já está definido em preços