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 InvoicePaid e SubscriptionCanceled.

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 use double.
  • 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_due contém apenas assinaturas ACTIVE. Queries para encontrar assinaturas vencidas sempre serão pequenas e rápidas.
  • A coluna version suporta 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:

  1. Criar fatura: encontrar assinaturas vencidas, criar invoice com status OPEN, depois avançar current_period_end para o novo período. Este passo apenas trabalha com o banco de dados.
  2. Cobrar: encontrar invoices OPEN com next_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 attemptCount e 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:

  1. Distribuir datas de vencimento: current_period_end segue 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.
  2. Fila natural: invoices ficam no DB com next_attempt_at. Workers processam gradualmente conforme capacidade. Nada é perdido, apenas mais lento.
  3. 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 @Scheduled sem mecanismo de lock ao rodar múltiplas instâncias: leva a processamento duplicado.
  • Calcular datas com LocalDateTime no fuso horário do servidor: erros de deslocamento e problemas no dia 31. Use Instant. Para períodos mensais, use ZonedDateTime.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 LOCKED para distribuir trabalho entre múltiplas instâncias, com restrição UNIQUE como 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.

system-designspring-bootbillingkafkaredis