Design de sistema de billing e renovação de assinatura
1. Visão geral
A renovação de assinatura parece simples: quando vence, cobra do cliente. Na prática, há muitos pontos onde algo pode dar errado. Você pode cobrar duas vezes, pular um período, ou derrubar o gateway de pagamento no primeiro dia do mês.
Este artigo passa por todo o design: requisitos, modelo de dados, API, fluxo de processamento e pontos problemáticos. Cada ponto difícil vem com uma solução em código Java 21, Spring Boot 3.x, PostgreSQL, Redis e Kafka. Ao final, você terá um framework de design robusto o suficiente para aplicar em um sistema real.
2. Requisitos
2.1. Requisitos funcionais
- O cliente assina um plano (
Plan) mensal ou anual. - O sistema renova automaticamente e cobra quando o período atual termina.
- O cliente pode cancelar imediatamente ou cancelar no final do período.
- O cliente pode mudar de plano no meio do período. A taxa é calculada proporcionalmente (proration).
- Se o pagamento falhar, o sistema tenta novamente conforme um cronograma (dunning). Após esgotar as tentativas, cancela a assinatura.
- Outros sistemas (email, autorização) recebem eventos
InvoicePaideSubscriptionCanceled.
2.2. Escala
- 5 milhões de assinaturas ativas.
- Em média, cerca de 170 mil renovações por dia.
- Pico de cerca de 1 milhão de renovações no primeiro dia do mês, devido a campanhas de promoção que agrupam datas de início.
- O gateway de pagamento suporta 200 TPS. Com 1 milhão de renovações, apenas a cobrança levaria cerca de 1,4 hora.
2.3. Restrições
- Nunca cobrar duas vezes pelo mesmo período.
- Nunca pular nenhum período, mesmo se uma instância morrer no meio do processamento.
- Valores monetários são armazenados como inteiros na unidade menor (
BIGINT). Não usedouble. - Horários são armazenados em UTC (
TIMESTAMPTZ). - O serviço roda múltiplas instâncias atrás de um load balancer.
3. Modelo de dados
O sistema tem três tabelas principais e uma tabela de outbox:
CREATE TABLE plan (
id BIGSERIAL PRIMARY KEY,
code VARCHAR(50) UNIQUE NOT NULL,
price_amount BIGINT NOT NULL,
currency CHAR(3) NOT NULL,
billing_interval VARCHAR(10) NOT NULL -- MONTH | YEAR
);
CREATE TABLE subscription (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL,
plan_id BIGINT NOT NULL REFERENCES plan(id),
status VARCHAR(20) NOT NULL, -- ACTIVE | PAST_DUE | CANCELED
current_period_end TIMESTAMPTZ NOT NULL,
cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE,
version BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX idx_sub_due ON subscription (current_period_end) WHERE status = 'ACTIVE';
CREATE TABLE invoice (
id BIGSERIAL PRIMARY KEY,
subscription_id BIGINT NOT NULL REFERENCES subscription(id),
period_start TIMESTAMPTZ NOT NULL,
period_end TIMESTAMPTZ NOT NULL,
amount BIGINT NOT NULL,
status VARCHAR(20) NOT NULL, -- OPEN | PAID | FAILED
attempt_count INT NOT NULL DEFAULT 0,
next_attempt_at TIMESTAMPTZ,
UNIQUE (subscription_id, period_start)
);
CREATE INDEX idx_inv_retry ON invoice (next_attempt_at) WHERE status = 'OPEN';
CREATE TABLE outbox_event (
id BIGSERIAL PRIMARY KEY,
aggregate_id BIGINT NOT NULL,
event_type VARCHAR(50) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ
);
Há algumas decisões dignas de nota:
UNIQUE (subscription_id, period_start)é a rede de segurança final. Mesmo com bugs no código, o banco de dados não permitirá criar duas faturas para o mesmo período.- O índice parcial
idx_sub_duecontém apenas assinaturasACTIVE. Queries para encontrar assinaturas vencidas sempre serão pequenas e rápidas. - A coluna
versionsuporta otimistic locking para operações de API como mudança de plano ou cancelamento.
4. API
| Method | Endpoint | Descrição |
|---|---|---|
POST |
/subscriptions |
Cria assinatura. Header Idempotency-Key obrigatório |
GET |
/subscriptions/{id} |
Visualiza status e período atual |
POST |
/subscriptions/{id}/cancel |
Body {"atPeriodEnd": true} |
POST |
/subscriptions/{id}/change-plan |
Body {"planCode": "PRO_MONTHLY"} |
POST |
/webhooks/payments |
Gateway de pagamento relata resultado assincronamente |
Todas as operações de escrita devem ser idempotentes. Com problemas de rede, retries de requisição, ou o cliente clicando “Assinar” duas vezes, apenas uma assinatura deve ser criada.
5. Fluxo de renovação
O fluxo de renovação se divide em dois passos independentes:
- Criar fatura: encontrar assinaturas vencidas, criar
invoicecom statusOPEN, depois avançarcurrent_period_endpara o novo período. Este passo apenas trabalha com o banco de dados. - Cobrar: encontrar invoices
OPENcomnext_attempt_at <= now, chamar o gateway de pagamento, depois registrar o resultado.
Por que separar: chamar o gateway de pagamento pode levar vários segundos. Se chamar imediatamente dentro de uma transação que está segurando um lock de row, você esgotará o connection pool muito rapidamente.
Primeiro, você precisa das seguintes dependências:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
6. Pontos difíceis e soluções
6.1. Concorrência: múltiplas instâncias rodando o job
Cada instância tem @Scheduled. Se todas fizerem SELECT das assinaturas vencidas simultaneamente, a mesma assinatura será processada múltiplas vezes. A solução é usar FOR UPDATE SKIP LOCKED:
public interface SubscriptionRepository extends JpaRepository<Subscription, Long> {
@Query(value = """
SELECT * FROM subscription
WHERE status = 'ACTIVE' AND current_period_end <= :now
ORDER BY current_period_end
LIMIT :batchSize
FOR UPDATE SKIP LOCKED
""", nativeQuery = true)
List<Subscription> lockDueBatch(Instant now, int batchSize);
}
FOR UPDATE bloqueia as linhas selecionadas. SKIP LOCKED faz com que outras instâncias pulem essas linhas e peguem o próximo lote, em vez de esperar. Assim cada instância recebe um conjunto separado. Para aumentar throughput, basta adicionar instâncias.
O serviço que cria faturas processa cada lote em uma transação:
@Service
@RequiredArgsConstructor
public class RenewalService {
private final SubscriptionRepository subscriptions;
private final InvoiceRepository invoices;
@Transactional
public int createDueInvoices(Instant now) {
List<Subscription> due = subscriptions.lockDueBatch(now, 500);
for (Subscription sub : due) {
if (sub.isCancelAtPeriodEnd()) {
sub.cancel();
continue;
}
Instant start = sub.getCurrentPeriodEnd();
Instant end = sub.getPlan().nextPeriodEnd(start);
invoices.save(Invoice.open(sub, start, end, sub.getPlan().getPriceAmount(), now));
sub.advancePeriod(end);
}
return due.size();
}
}
No loop, se a assinatura tem a flag cancelAtPeriodEnd, ela é cancelada em vez de renovada. As demais assinaturas recebem uma invoice OPEN com next_attempt_at = now, depois o período é avançado para o novo marco. Ambas as mudanças estão na mesma transação, então não há estado “fatura criada mas período não avançado”.
O scheduler chama este serviço repetidamente até não haver mais trabalho:
@Scheduled(fixedDelay = 10_000)
public void renew() {
while (renewalService.createDueInvoices(Instant.now()) > 0) { }
}
Operações de API como mudança de plano ou cancelamento também podem colidir com o job de renovação. A coluna version com a anotação @Version do JPA lida com isso. Quem fizer commit depois receberá OptimisticLockException. O cliente apenas precisa ler o status novamente e tentar de novo.
6.2. Nunca cobrar duas vezes: idempotency key
Este é o ponto mais difícil. Você chama o gateway de pagamento e recebe um timeout. Você não sabe se o cliente foi cobrado ou não. Se fazer retry cegamente, pode cobrar duas vezes.
A solução é enviar uma idempotency key. O gateway, ao receber a mesma key novamente, retorna o resultado anterior sem cobrar novamente:
public void chargeInvoice(long invoiceId) {
Invoice inv = invoices.findById(invoiceId).orElseThrow();
if (inv.getStatus() != InvoiceStatus.OPEN) {
return;
}
String key = "inv-%d-try-%d".formatted(inv.getId(), inv.getAttemptCount());
PaymentResult result = paymentGateway.charge(inv.getCustomerId(), inv.getAmount(), key);
billingTx.recordResult(invoiceId, result);
}
A key inclui invoiceId e attemptCount, não apenas invoiceId. Muitos gateways cacheia até resultados de falha pela key. Se usar uma única key, uma tentativa três dias depois (quando o cliente já recarregou) ainda retornaria o erro antigo “cartão recusado”.
Regras para incrementar attemptCount:
- Sucesso: invoice muda para
PAID. - Recusado claramente (saldo insuficiente, cartão expirado): incrementar
attemptCounte agendar nova tentativa. - Resultado incerto (timeout, erro 5xx): manter
attemptCount. A próxima tentativa usa a mesma key, então o gateway retornará o resultado real da tentativa anterior.
Nota: Webhooks do gateway podem chegar cedo, tarde, ou múltiplas vezes. O handler do webhook deve verificar o status da invoice antes de atualizar. Se a invoice já está
PAID, ignora.
6.3. Cronograma de retentativas quando pagamento falha (dunning)
O cronograma de retentativas é uma função pura, muito fácil de testar:
public final class DunningPolicy {
private static final List<Duration> SCHEDULE =
List.of(Duration.ofDays(1), Duration.ofDays(3), Duration.ofDays(5));
public static Optional<Instant> nextAttempt(int failedAttempts, Instant now) {
if (failedAttempts > SCHEDULE.size()) {
return Optional.empty();
}
long jitterMinutes = ThreadLocalRandom.current().nextLong(0, 120);
return Optional.of(now.plus(SCHEDULE.get(failedAttempts - 1)).plusSeconds(jitterMinutes * 60));
}
}
Após a primeira falha, o sistema tenta novamente após 1 dia. Na segunda, 3 dias. Na terceira, 5 dias. Após a quarta tentativa, a função retorna Optional.empty(). A invoice muda para FAILED e a assinatura para CANCELED. Durante o dunning, a assinatura fica em estado PAST_DUE e o cliente ainda pode usar o serviço (período de graça).
O jitter aleatório de 0–120 minutos ajuda a evitar que todas as retentativas se agrupem no mesmo momento.
6.4. Consistência entre database e Kafka: padrão outbox
Após marcar uma invoice como PAID, você precisa enviar o evento InvoicePaid. Se fazer commit no DB e depois enviar para Kafka, a instância pode morrer no meio e o evento se perder. Se enviar para Kafka primeiro e depois fazer rollback do DB, outros sistemas receberão um evento “fantasma”.
O padrão outbox resolve escrevendo o evento na tabela outbox_event na mesma transação:
@Transactional
public void recordResult(long invoiceId, PaymentResult result) {
Invoice inv = invoices.findById(invoiceId).orElseThrow();
switch (result.status()) {
case SUCCESS -> {
inv.markPaid();
outbox.save(OutboxEvent.of(inv.getId(), "InvoicePaid", inv.toJson()));
}
case DECLINED -> inv.recordFailure(Instant.now());
case UNKNOWN -> inv.retryLater(Instant.now().plus(Duration.ofMinutes(15)));
}
}
Esta função roda em sua própria transação, separada da chamada ao gateway de pagamento. Um relay executado periodicamente lerá o outbox e enviará para Kafka:
@Scheduled(fixedDelay = 1_000)
@Transactional
public void publishOutbox() {
for (OutboxEvent e : outbox.lockUnpublished(200)) {
kafka.send("billing-events", String.valueOf(e.getAggregateId()), e.getPayload()).join();
e.markPublished(Instant.now());
}
}
lockUnpublished também usa FOR UPDATE SKIP LOCKED como na seção 6.1. A chamada join() garante que Kafka recebeu a mensagem antes de marcar published_at. A chave da mensagem é aggregateId, então eventos da mesma invoice sempre vão para a mesma partição e mantêm a ordem correta.
Essa abordagem garante at-least-once. Se o relay morrer após enviar mas antes de fazer commit, o evento será reenviado. Por isso o consumer deve ser idempotente, por exemplo salvando o eventId já processado.
6.5. Picos de carga no primeiro dia do mês
No primeiro dia do mês pode haver até 1 milhão de invoices precisando ser cobradas. O gateway só suporta 200 TPS. Há três camadas de defesa:
- Distribuir datas de vencimento:
current_period_endsegue o momento exato em que o cliente se inscreveu, incluindo hora e minuto, não arredondado para 00:00. Só isso já distribui a carga ao longo de 24 horas. - Fila natural: invoices ficam no DB com
next_attempt_at. Workers processam gradualmente conforme capacidade. Nada é perdido, apenas mais lento. - Rate limiting global com Redis: todas as instâncias compartilham um contador.
@Component
@RequiredArgsConstructor
public class GatewayRateLimiter {
private final StringRedisTemplate redis;
private final int limitPerSecond = 200;
public boolean tryAcquire() {
String key = "rl:gateway:" + Instant.now().getEpochSecond();
Long count = redis.opsForValue().increment(key);
if (count != null && count == 1) {
redis.expire(key, Duration.ofSeconds(2));
}
return count != null && count <= limitPerSecond;
}
}
Cada segundo tem sua própria chave. INCR é atomic, então mesmo com 10 instâncias, o número total de chamadas em um segundo não ultrapassa 200. Workers que recebem false deixam a invoice para a próxima rodada. Este é o algoritmo fixed window. É simples e adequado aqui, embora possa ter pequenos picos na borda entre dois segundos.
Nota: Se Redis ficar inacessível, falhe de forma fechada (pause a cobrança) em vez de ignorar o rate limit. Mais lento por alguns minutos é melhor que ter a conta merchant bloqueada pelo gateway.
7. Cobrança proporcional ao mudar de plano e testes
Quando o cliente faz upgrade no meio do período, o sistema reembolsa a parte não utilizada do plano antigo e cobra o restante do novo plano:
public final class Proration {
public static long amountDue(long oldPrice, long newPrice,
Instant periodStart, Instant periodEnd, Instant changeAt) {
long total = Duration.between(periodStart, periodEnd).toSeconds();
long remaining = Duration.between(changeAt, periodEnd).toSeconds();
long credit = Math.floorDiv(oldPrice * remaining, total);
long charge = Math.floorDiv(newPrice * remaining, total);
return charge - credit;
}
}
A função multiplica antes de dividir para evitar perda de precisão. Todos os cálculos usam long. Resultado negativo significa que o cliente fez downgrade, e esse valor é registrado como crédito para o próximo período.
Testes usando JUnit 5 e AssertJ:
class BillingRulesTest {
private final Instant start = Instant.parse("2026-10-01T00:00:00Z");
private final Instant end = Instant.parse("2026-10-31T00:00:00Z");
@Test
void upgradeAtHalfPeriodChargesHalfOfDifference() {
Instant mid = Instant.parse("2026-10-16T00:00:00Z");
long due = Proration.amountDue(100_000, 300_000, start, end, mid);
assertThat(due).isEqualTo(100_000);
}
@Test
void stopsRetryingAfterThreeScheduledAttempts() {
Instant now = Instant.parse("2026-10-01T00:00:00Z");
assertThat(DunningPolicy.nextAttempt(1, now)).get()
.satisfies(t -> assertThat(t).isBetween(now.plus(Duration.ofDays(1)),
now.plus(Duration.ofDays(1)).plus(Duration.ofHours(2))));
assertThat(DunningPolicy.nextAttempt(4, now)).isEmpty();
}
}
O primeiro teste muda de plano exatamente no meio de um período de 30 dias, de 100.000 para 300.000. O crédito é 50.000, a cobrança é 150.000, então o cliente paga 100.000 adicionais. O segundo teste verifica duas coisas: a primeira retentativa cai entre 1 dia mais o jitter, e após a quarta falha não há mais cronograma de retentativa.
BillingRulesTest > upgradeAtHalfPeriodChargesHalfOfDifference() PASSED
BillingRulesTest > stopsRetryingAfterThreeScheduledAttempts() PASSED
Para a parte de concorrência na seção 6.1, escreva um teste de integração adicional com Testcontainers PostgreSQL. Faça duas threads chamarem createDueInvoices simultaneamente, depois assert que o número de invoices é exatamente o número de assinaturas vencidas.
8. Erros comuns
- Chamar o gateway de pagamento dentro de
@Transactional: a transação segura o lock e a conexão durante a espera de rede. Separe em passos: ler, chamar fora, escrever resultado. - Usar
@Scheduledsem mecanismo de lock ao rodar múltiplas instâncias: leva a processamento duplicado. - Calcular datas com
LocalDateTimeno fuso horário do servidor: erros de deslocamento e problemas no dia 31. UseInstant. Para períodos mensais, useZonedDateTime.plusMonths(1)no fuso do cliente. Este método automaticamente ajusta 31/1 para 28 ou 29/2. - Usar uma única idempotency key para todas as tentativas: erros antigos ficam em cache e são retornados sempre.
- Não construa todo este sistema se você tem apenas alguns milhares de clientes e modelo de preço simples. O serviço de billing do gateway é geralmente mais barato que custo de manutenção. Só compensa se você precisa de lógica de preço customizada, múltiplos gateways, ou controle rígido de dados.
9. Conclusão
O artigo projetou um sistema de billing e renovação de assinatura que suporta escala de milhões de clientes. Os pontos principais incluem:
- Separar criação de faturas e cobrança em dois passos.
- Usar
FOR UPDATE SKIP LOCKEDpara distribuir trabalho entre múltiplas instâncias, com restriçãoUNIQUEcomo rede de segurança. - Usar idempotency key por tentativa para nunca cobrar duas vezes.
- Usar padrão outbox para manter consistência entre database e Kafka.
- Combinar distribuição de datas de vencimento, fila em DB e rate limit de Redis para superar picos de carga.
A partir deste framework, você pode expandir com impostos, códigos de desconto ou cobrança por uso sem alterar o fluxo central.