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.
| Status | Significado | Comportamento típico do produto |
|---|---|---|
trialing | Em período de teste, antes do primeiro período pago. | Acesso completo, com a data de fim do teste bem visível. |
active | O 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_due | A última cobrança de renovação falhou. | Uma decisão sua: carência com aviso claro ou acesso restrito. |
paused | Nenhuma renovação é cobrada até a retomada. | Acesso suspenso, dados preservados e um jeito óbvio de retomar. |
canceled | Encerrada. 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.
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).
| De | Para | O que acontece | Eventos |
|---|---|---|---|
| — | active | O primeiro período é pago num link de pagamento, ou a assinatura é criada pela API. | subscription.created, charge.paid |
active | active | Uma cobrança de renovação é paga e um novo período começa. | charge.paid, subscription.renewed |
active | past_due | Uma cobrança de renovação falha. | charge.failed, subscription.past_due |
trialing / active / past_due | paused | Você pausa a assinatura. | subscription.paused |
paused | active | Você retoma. Se o período pago já acabou, um novo é cobrado na hora. | subscription.resumed e, quando cobrado, subscription.renewed |
qualquer um, menos canceled | canceled | Cancelamento imediato, ou o fim do período com cancel_at_period_end. | subscription.canceled |
Informação: Duas regras ainda em definição
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.
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.
trialingeactive: acesso completo atécurrent_period_end.activecomcancel_at_period_end: acesso completo até a data emcanceled_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.
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.
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.
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.
// 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_atou consulte a assinatura comGET /v1/subscriptions/{id}na dúvida. - Ouça também os
charge.*. Cada período é uma cobrança, entãocharge.paidecharge.failedtrazem 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.
- Booleanos como
is_payingouis_active. Eles espremem cinco estados em dois e obrigam cada tela a adivinhar. Guarde o status e calcule o acesso numa função só. - 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.
- Falhas silenciosas. Uma assinatura em
past_dueque ninguém percebe é receita perdida e cliente confuso. Avise o cliente, mostre para o time e deixe a recuperação a um clique. - Cortar o acesso no pedido de cancelamento. Cancelamento agendado ainda é período pago. Espere o
subscription.canceled. - Tratar pausa como cancelamento. Quem pausa pretende voltar. Guarde os dados e deixe a retomada à vista.
- Reagir ao navegador. Voltar do checkout não prova pagamento. Libere acesso com base nos eventos.
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.
