Diseño de un sistema de facturación y renovación de suscripciones

1. Descripción general

Renovar una suscripción suena fácil: llega el plazo, desconectas al cliente. Pero en la práctica hay muchos lugares donde puedes cometer errores. Podrías descontar dos veces, omitir un período, o saturar la puerta de pago el día 1 de cada mes.

Este artículo recorre todo el diseño: requisitos, modelo de datos, APIs, flujos de procesamiento y puntos problemáticos. Cada punto problemático viene con una solución en código Java 21, Spring Boot 3.x, PostgreSQL, Redis y Kafka. Después de leerlo, tendrás un marco de diseño sólido que puedes aplicar a un sistema real.

2. Requisitos del problema

2.1. Requisitos funcionales

  • Los clientes se suscriben a un plan (Plan) mensual o anual.
  • El sistema renueva automáticamente y desconecta dinero cuando termina el período actual.
  • Los clientes pueden cancelar inmediatamente o cancelar al final del período.
  • Los clientes pueden cambiar de plan a mitad del período. El cobro se calcula prorrateado (proration).
  • Cuando el pago falla, el sistema reintenta según un cronograma (dunning). Después de agotar los reintentos se cancela la suscripción.
  • Otros sistemas (correo, permisos) reciben eventos como InvoicePaid y SubscriptionCanceled.

2.2. Escala

  • 5 millones de suscripciones activas.
  • Aproximadamente 170.000 renovaciones diarias en promedio.
  • Pico de alrededor de 1 millón el día 1, debido a campañas que agrupan el inicio de suscripciones.
  • La puerta de pago soporta 200 TPS. Con 1 millón de eventos, solo el cobro toma aproximadamente 1,4 horas.

2.3. Restricciones

  • Nunca descontar dos veces por el mismo período.
  • Nunca omitir un período, incluso si una instancia se detiene a mitad del camino.
  • El dinero se almacena como un entero en la unidad más pequeña (BIGINT). No usar double.
  • El tiempo se almacena en UTC (TIMESTAMPTZ).
  • El servicio se ejecuta en múltiples instancias detrás de un balanceador de carga.

3. Modelo de datos

El sistema tiene tres tablas principales y una tabla 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
);

Hay varias decisiones dignas de mención:

  • UNIQUE (subscription_id, period_start) es una red de seguridad final. Incluso si el código tiene un error, la base de datos no permitirá crear dos facturas para el mismo período.
  • El índice parcial idx_sub_due solo contiene suscripciones ACTIVE. Una consulta para encontrar suscripciones que vencen será siempre pequeña y rápida.
  • La columna version sirve para optimistic locking en operaciones desde la API como cambiar de plan o cancelar.

4. APIs

Método Endpoint Descripción
POST /subscriptions Crear suscripción. Requiere encabezado Idempotency-Key
GET /subscriptions/{id} Ver estado y período actual
POST /subscriptions/{id}/cancel Body {"atPeriodEnd": true}
POST /subscriptions/{id}/change-plan Body {"planCode": "PRO_MONTHLY"}
POST /webhooks/payments La puerta de pago reporta resultado asincrónico

Todas las operaciones de escritura deben ser idempotentes. La red móvil o reintentos de solicitud, y los clientes hacen clic en “Suscribirse” dos veces, aún así solo se crea una suscripción.

5. Flujo de renovación

El flujo de renovación se divide en dos pasos independientes:

  1. Crear factura: encontrar suscripciones que vencen, crear invoice con estado OPEN, luego avanzar current_period_end al nuevo período. Este paso solo trabaja con la base de datos.
  2. Cobrar: encontrar facturas OPEN con next_attempt_at <= now, llamar a la puerta de pago, luego grabar el resultado.

Razón para separar: llamar a la puerta de pago puede tomar varios segundos. Si llamamos dentro de una transacción que sostiene un bloqueo de fila, agotarás el connection pool muy rápidamente.

Primero, necesitas las siguientes dependencias:

<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. Puntos problemáticos y soluciones

6.1. Contención: múltiples instancias ejecutando el job

Cada instancia tiene @Scheduled. Si todas hacen SELECT de suscripciones que vencen, la misma suscripción se procesará múltiples veces. La solución es 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 bloquea las filas seleccionadas. SKIP LOCKED hace que otras instancias las salten y obtengan el siguiente lote, en lugar de esperar. Por lo tanto, cada instancia recibe su propio conjunto. Para aumentar el rendimiento, simplemente añade más instancias.

El servicio que crea facturas procesa cada lote en una transacción:

@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();
    }
}

En el bucle, la suscripción con la bandera cancelAtPeriodEnd se cancela en lugar de renovarse. Las suscripciones restantes obtienen un invoice OPEN con next_attempt_at = now, luego se avanza el período al nuevo hito. Ambos cambios están en la misma transacción, así no hay un estado “se creó la factura pero no se avanzó el período”.

El scheduler llama al servicio repetidamente hasta que se acaba el trabajo:

@Scheduled(fixedDelay = 10_000)
public void renew() {
    while (renewalService.createDueInvoices(Instant.now()) > 0) { }
}

Las operaciones desde la API como cambiar de plan o cancelar también pueden chocar con el job de renovación. La columna version junto con la anotación @Version de JPA maneja esto. El que hace commit último recibe OptimisticLockException. El cliente solo necesita releer el estado e intentar de nuevo.

6.2. No descontar dos veces: idempotency key

Este es el punto más difícil. Llamas a la puerta de pago y recibes un timeout. No sabes si al cliente ya se le descontó dinero. Si reintentamos ciegamente, podrías descontar dos veces.

La solución es enviar una idempotency key. Si la puerta de pago recibe la misma clave de nuevo, devuelve el resultado anterior sin descontar más:

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);
}

La clave consta de invoiceId y attemptCount, no solo invoiceId. Muchas puertas de pago almacenan incluso resultados fallidos según la clave. Si usamos una clave única, el próximo reintento tres días después (cuando el cliente ha depositado dinero) seguirá recibiendo el error antiguo de “tarjeta rechazada”.

Reglas para incrementar attemptCount:

  • Éxito: factura pasa a PAID.
  • Rechazado claramente (fondos insuficientes, tarjeta vencida): incrementar attemptCount y programar reintento.
  • Resultado incierto (timeout, error 5xx): mantener attemptCount igual. El próximo reintento usa la misma clave, así la puerta de pago devuelve el resultado real del intento anterior.

Nota: El webhook de la puerta de pago puede llegar antes, después, o varias veces. El handler del webhook debe verificar el estado de la factura antes de actualizar. Una factura ya PAID se ignora.

6.3. Cronograma de reintentos cuando falla el pago (dunning)

El cronograma de reintentos es una función pura, muy fácil de probar:

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));
    }
}

Después del primer intento fallido, el sistema reintenta después de 1 día. El segundo es 3 días, el tercero es 5 días. Después del cuarto, la función devuelve Optional.empty(). En ese punto, la factura pasa a FAILED y la suscripción a CANCELED. Durante el dunning, la suscripción está en estado PAST_DUE y el cliente aún puede usar el servicio (período de gracia).

La parte de jitter, aleatoriamente 0–120 minutos, ayuda a que los reintentos no converjan en el mismo momento.

6.4. Consistencia entre base de datos y Kafka: outbox pattern

Después de marcar una factura como PAID, necesitas enviar el evento InvoicePaid. Si haces commit en DB primero y luego Kafka, la instancia podría morir en el medio y el evento se pierde. Si envías a Kafka primero y luego DB hace rollback, otros sistemas reciben un evento “fantasma”.

El outbox pattern lo resuelve escribiendo el evento en la tabla outbox_event en la misma transacción:

@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 función se ejecuta en su propia transacción, separada de la llamada a la puerta de pago. Un relé que se ejecuta periódicamente leerá el outbox y lo empujará a 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 también usa FOR UPDATE SKIP LOCKED como en la sección 6.1. La llamada join() asegura que Kafka ha recibido el mensaje antes de marcar published_at. La clave del mensaje es aggregateId, por lo que los eventos de la misma factura siempre van a la misma partición y mantienen el orden correcto.

Este enfoque garantiza at-least-once. Si el relé muere después de enviar pero antes de hacer commit, el evento se reenviará. Por lo tanto, el consumer debe ser idempotente, por ejemplo almacenando eventId ya procesado.

6.5. Picos de carga en el día 1

El día 1 podría tener hasta 1 millón de facturas para cobrar. La puerta de pago solo soporta 200 TPS. Hay tres capas de defensa:

  1. Dispersar puntos de pago: current_period_end se toma en el momento en que el cliente se suscribe, incluyendo hora y minuto, no redondeado a las 00:00. Solo esto ya distribuye la carga a lo largo de 24 horas.
  2. Cola natural: las facturas están en DB con next_attempt_at. Los workers las procesan gradualmente según su capacidad. Nada se pierde, solo toma más tiempo.
  3. Límite de velocidad global con Redis: todas las instancias comparten un 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 tiene su propia clave. INCR es una operación atómica, así que incluso con 10 instancias el número total de llamadas en un segundo no excede 200. Un worker que recibe false deja la factura para el siguiente ciclo. Este es el algoritmo fixed window. Es simple y suficiente aquí, aunque puede superar ligeramente el límite en los bordes entre segundos.

Nota: Si Redis no está disponible, falla de forma cerrada (pausa temporalmente los cobros) en lugar de ignorar el límite de velocidad. Lentitud de unos minutos es mejor que que la puerta de pago bloquee tu cuenta de merchant.

7. Prorrateo cuando cambias de plan a mitad del período y pruebas

Cuando un cliente sube de plan a mitad del período, el sistema reembolsa la parte sin usar del plan antiguo y cobra la parte restante del plan nuevo:

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;
    }
}

La función multiplica primero y luego divide para evitar pérdida de precisión. Todo el cálculo usa long. Un resultado negativo significa que el cliente bajó de plan, y ese monto se registra como crédito para el próximo período.

Prueba con JUnit 5 y 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();
    }
}

La primera prueba cambia de plan exactamente a mitad de un período de 30 días, de 100.000 a 300.000. El crédito es 50.000, el cargo es 150.000, así que el cliente paga 100.000 más. La segunda prueba verifica dos cosas: el primer reintento cae entre 1 día más jitter, y después del cuarto intento fallido no hay más cronograma de reintentos.

BillingRulesTest > upgradeAtHalfPeriodChargesHalfOfDifference() PASSED
BillingRulesTest > stopsRetryingAfterThreeScheduledAttempts() PASSED

Con la contención de la sección 6.1, escribe una prueba de integración adicional usando Testcontainers PostgreSQL. Haz que dos threads llamen a createDueInvoices simultáneamente, luego afirma que el número de facturas es exacto.

8. Errores comunes

  • Llamar la puerta de pago dentro de @Transactional: la transacción mantiene el bloqueo y la conexión durante toda la espera de red. Separa en: paso de lectura, paso de llamada externa, paso de grabación de resultado.
  • Usar @Scheduled sin mecanismo de bloqueo cuando se ejecuta en múltiples instancias: lleva a procesamiento duplicado.
  • Calcular fechas con LocalDateTime según la zona horaria del servidor: causa errores de cambio de hora y errores en el día 31. Usa Instant. Para períodos mensuales, usa ZonedDateTime.plusMonths(1) según la zona horaria del cliente. Este método automáticamente ajusta 31/1 a 28 o 29/2.
  • Usar la misma idempotency key para todos los reintentos: el error antiguo se cachea y se devuelve indefinidamente.
  • No deberías construir por tu cuenta todo este sistema si solo tienes algunos miles de clientes y un modelo de precios simple. El billing incorporado de puertas de pago generalmente cuesta menos que el mantenimiento. Construcción propia solo vale la pena si necesitas lógica de precios personalizada, múltiples puertas de pago, o control estricto de datos.

9. Conclusión

El artículo ha diseñado un sistema de facturación y renovación de suscripciones que soporta millones de clientes. Los puntos clave incluyen:

  • Separar la creación de facturas y el cobro en dos pasos.
  • Usar FOR UPDATE SKIP LOCKED para distribuir trabajo entre múltiples instancias, junto con restricción UNIQUE como red de seguridad.
  • Usar idempotency key por cada intento para nunca descontar dos veces.
  • Usar outbox pattern para mantener consistencia entre base de datos y Kafka.
  • Combinar dispersión de puntos de pago, cola en DB y rate limit Redis para superar picos de carga.

Desde este marco, puedes expandir agregando impuestos, códigos de descuento o facturación por uso sin cambiar el flujo principal.

system-designspring-bootbillingkafkaredis