Conception d'un système de facturation et de renouvellement

1. Aperçu

Le renouvellement d’abonnement paraît simple : à l’échéance, débiter le client. En réalité, il y a de nombreux pièges. Vous pouvez facturer deux fois, oublier une période, ou surcharger la passerelle de paiement le 1er du mois.

Cet article couvre la conception complète : les exigences, le modèle de données, les API, les flux de traitement et les points difficiles. Chaque point difficile s’accompagne d’une solution en code Java 21, Spring Boot 3.x, PostgreSQL, Redis et Kafka. Après cette lecture, vous aurez un cadre de conception suffisamment robuste pour l’appliquer à un vrai système.

2. Exigences du problème

2.1. Exigences fonctionnelles

  • Les clients s’abonnent à un forfait (Plan) mensuel ou annuel.
  • Le système se renouvelle automatiquement et facture lorsque la période actuelle se termine.
  • Les clients peuvent annuler immédiatement ou à la fin de la période.
  • Les clients peuvent changer de forfait en cours de période. Les frais sont calculés au prorata du temps restant (proration).
  • En cas d’échec du paiement, le système réessaie selon un calendrier (dunning). Après épuisement des tentatives, l’abonnement est annulé.
  • Les autres systèmes (e-mail, autorisation) reçoivent les événements InvoicePaid et SubscriptionCanceled.

2.2. Échelle

  • 5 millions d’abonnements actifs.
  • En moyenne, environ 170 000 renouvellements par jour.
  • Un pic d’environ 1 million le 1er du mois, en raison des campagnes de promotion concentrées sur cette date.
  • La passerelle de paiement est limitée à 200 TPS. Avec 1 million de transactions, seule la facturation prend environ 1,4 heure.

2.3. Contraintes

  • Ne jamais facturer deux fois pour la même période.
  • Ne jamais oublier une période, même si une instance meurt en cours de route.
  • Stocker l’argent en entiers selon la plus petite unité (BIGINT). Ne pas utiliser double.
  • Stocker l’heure en UTC (TIMESTAMPTZ).
  • Le service s’exécute sur plusieurs instances derrière un load balancer.

3. Modèle de données

Le système a trois tables principales et une table 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
);

Il y a quelques décisions notables :

  • UNIQUE (subscription_id, period_start) est le filet de sécurité ultime. Même en cas d’erreur dans le code, la base de données empêche la création de deux factures pour la même période.
  • L’index partiel idx_sub_due ne contient que les abonnements ACTIVE. Une requête pour trouver les abonnements arrivant à échéance reste donc petite et rapide.
  • La colonne version permet le verrouillage optimiste pour les opérations API telles que le changement de forfait ou l’annulation.

4. API

Méthode Endpoint Description
POST /subscriptions Créer un abonnement. L’en-tête Idempotency-Key est obligatoire
GET /subscriptions/{id} Voir l’état et la période actuelle
POST /subscriptions/{id}/cancel Corps {"atPeriodEnd": true}
POST /subscriptions/{id}/change-plan Corps {"planCode": "PRO_MONTHLY"}
POST /webhooks/payments La passerelle de paiement signale les résultats de manière asynchrone

Toutes les opérations d’écriture doivent être idempotentes. Avec les défaillances réseau ou les requêtes de relance, et si un client clique deux fois sur « S’abonner », un seul abonnement est créé.

5. Flux de renouvellement

Le flux de renouvellement se divise en deux étapes indépendantes :

  1. Créer une facture : trouver les abonnements arrivant à échéance, créer une invoice avec le statut OPEN, puis avancer current_period_end à la nouvelle période. Cette étape ne fonctionne qu’avec la base de données.
  2. Collecter le paiement : trouver les factures OPEN avec next_attempt_at <= now, appeler la passerelle de paiement, puis enregistrer le résultat.

Raison de la séparation : appeler la passerelle de paiement peut prendre plusieurs secondes. Si vous appelez directement dans une transaction verrouillant les lignes, vous épuiserez le pool de connexions très rapidement.

D’abord, vous avez besoin des dépendances suivantes :

<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. Points difficiles et solutions

6.1. Concurrence : plusieurs instances exécutent le job

Chaque instance a @Scheduled. Si toutes font un SELECT des abonnements arrivant à échéance, le même abonnement sera traité plusieurs fois. La solution est d’utiliser 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 verrouille les lignes sélectionnées. SKIP LOCKED fait que les autres instances ignorent ces lignes et prennent le lot suivant au lieu d’attendre. Chaque instance reçoit donc un ensemble distinct. Pour augmenter le débit, vous n’avez qu’à ajouter une instance.

Le service de création de facture traite chaque lot dans une transaction :

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

Dans la boucle, les abonnements avec le drapeau cancelAtPeriodEnd sont annulés au lieu d’être renouvelés. Les autres abonnements reçoivent une facture OPEN avec next_attempt_at = now, puis leur période avance vers le nouveau repère. Les deux modifications sont dans la même transaction, donc il n’y a pas d’état « facture créée mais période non avancée ».

Le scheduler appelle ce service en boucle jusqu’à ce qu’il n’y ait plus de travail :

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

Les opérations API comme le changement de forfait ou l’annulation peuvent également entrer en conflit avec le job de renouvellement. La colonne version et l’annotation @Version de JPA gèrent cela. Le côté qui fait un commit après recevra une OptimisticLockException. Le client n’a qu’à relire l’état et réessayer.

6.2. Ne pas facturer deux fois : idempotency key

C’est le point le plus difficile. Vous appelez la passerelle de paiement et vous recevez un timeout. Vous ne savez pas si le client a déjà été facturé. Si vous relancez aveuglément, vous pouvez facturer deux fois.

La solution consiste à envoyer une clé d’idempotence. La passerelle de paiement qui reçoit la même clé retournera le ancien résultat sans débiter à nouveau :

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 clé comprend invoiceId et attemptCount, pas seulement invoiceId. De nombreuses passerelles de paiement stockent aussi les résultats d’échec selon la clé. Si vous utilisez une clé unique, une relance trois jours plus tard (quand le client a recrédité son compte) recevra toujours le ancien message d’erreur « carte refusée ».

Règles pour augmenter attemptCount :

  • Succès : la facture passe à PAID.
  • Refus clairement indiqué (fonds insuffisants, carte expirée) : augmenter attemptCount et programmer une nouvelle tentative.
  • Résultat incertain (timeout, erreur 5xx) : garder inchangé attemptCount. La prochaine relance utilisera exactement la même clé, donc la passerelle retournera le vrai résultat de la tentative précédente.

Remarque : Les webhooks de la passerelle peuvent arriver avant, après, ou plusieurs fois. Le handler webhook doit vérifier l’état de la facture avant de la mettre à jour. Les factures PAID sont ignorées.

6.3. Calendrier de relance en cas d’échec du paiement (dunning)

Le calendrier de relance est une fonction pure, donc très facile à tester :

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

Après le premier échec, le système réessaie après 1 jour. Après le deuxième après 3 jours, après le troisième après 5 jours. Après la quatrième tentative, la fonction retourne Optional.empty(). La facture passe alors à FAILED et l’abonnement passe à CANCELED. Pendant le dunning, l’abonnement est en état PAST_DUE et le client peut toujours utiliser le service (période de grâce).

La gigue aléatoire de 0 à 120 minutes aide à éviter que les relances se concentrent au même moment.

6.4. Cohérence entre base de données et Kafka : pattern outbox

Après avoir marqué une facture PAID, vous devez envoyer l’événement InvoicePaid. Si vous commitez la BD puis envoyez Kafka, une instance peut mourir au milieu et l’événement se perd. Si vous envoyez Kafka d’abord puis la BD fait un rollback, les autres systèmes reçoivent un événement « fantôme ».

Le pattern outbox résout cela en écrivant l’événement dans la table outbox_event dans la même transaction :

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

Cette fonction s’exécute dans sa propre transaction, séparée de l’appel à la passerelle de paiement. Un relais qui s’exécute périodiquement lira l’outbox et l’enverra à 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 utilise aussi FOR UPDATE SKIP LOCKED comme à la section 6.1. L’appel join() garantit que Kafka a reçu le message avant de marquer published_at. La clé du message est aggregateId, donc les événements d’une même facture vont toujours dans la même partition et conservent le bon ordre.

Cette approche garantit at-least-once. Si le relais meurt après l’envoi mais avant le commit, l’événement sera renvoyé. Le consumer doit donc être idempotent, par exemple en stockant les eventId déjà traités.

6.5. Pics de charge le 1er du mois

Le 1er du mois peut avoir jusqu’à 1 million de factures à collecter. La passerelle de paiement ne supporte que 200 TPS. Il y a trois niveaux de défense :

  1. Étaler les dates d’échéance : current_period_end est basé sur le moment où le client s’est abonné, y compris l’heure et les minutes, au lieu d’arrondir à 00:00. Cela seul étale déjà la charge sur 24 heures.
  2. File d’attente naturelle : les factures restent dans la BD avec next_attempt_at. Les workers les traitent progressivement. Rien n’est perdu, juste un peu plus lent.
  3. Limite de débit global avec Redis : toutes les instances partagent un compteur commun.
@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;
    }
}

Chaque seconde a sa propre clé. INCR est une opération atomique, donc même avec 10 instances, le nombre total d’appels par seconde ne dépassera pas 200. Un worker qui reçoit false laisse la facture pour le tour suivant. C’est un algorithme de fenêtre fixe. Il est simple et suffisant ici, bien qu’il puisse y avoir une légère surcharge à la limite entre deux secondes.

Remarque : Si Redis n’est pas accessible, échouez fermé (suspendez temporairement la collecte) au lieu d’ignorer la limite de débit. Être lent de quelques minutes vaut mieux que de se faire bloquer par la passerelle de paiement.

7. Facturation lors du changement de forfait en cours de période et tests

Quand un client augmente de forfait en cours de période, le système rembourse la partie inutilisée du ancien forfait et facture la partie restante du nouveau forfait :

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 fonction multiplie d’abord puis divise, pour éviter une perte de précision. Tous les calculs utilisent long. Un résultat négatif signifie que le client baisse de forfait, et ce montant est enregistré comme crédit pour la prochaine période.

Test avec JUnit 5 et 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();
    }
}

Le premier test change de forfait exactement au milieu d’une période de 30 jours, de 100 000 à 300 000. Le crédit est de 50 000, la charge de 150 000, donc le client paie 100 000 de plus. Le deuxième test vérifie deux choses : la première relance tombe dans une fenêtre de 1 jour plus la gigue, et après le quatrième échec, il n’y a plus de calendrier de relance.

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

Pour la partie concurrence de la section 6.1, écrivez un test d’intégration supplémentaire avec Testcontainers PostgreSQL. Faites appeler createDueInvoices par deux threads en même temps, puis vérifiez que le nombre de factures correspond exactement au nombre d’abonnements arrivant à échéance.

8. Erreurs courantes

  • Appeler la passerelle de paiement dans @Transactional : la transaction maintient le verrou et la connexion pendant toute l’attente réseau. Séparez en étape de lecture, étape d’appel et étape d’enregistrement du résultat.
  • Utiliser @Scheduled sans mécanisme de verrouillage sur plusieurs instances : cela mène à un traitement dupliqué.
  • Calculer les dates avec LocalDateTime selon le fuseau horaire du serveur : provoque des erreurs de décalage et des problèmes le 31. Utilisez Instant. Pour les périodes mensuelles, utilisez ZonedDateTime.plusMonths(1) selon le fuseau horaire du client. Cette méthode ajuste automatiquement le 31/1 au 28 ou 29/2.
  • Utiliser la même clé d’idempotence pour tous les essais : les anciennes erreurs sont cachées et renvoyées indéfiniment.
  • Ne doit pas construire l’ensemble de ce système si vous avez seulement quelques milliers de clients et un modèle tarifaire simple. Les services de facturation intégrés de la passerelle coûtent souvent moins cher que la maintenance. Ne construisez que si vous avez besoin de logique tarifaire personnalisée, de multiples passerelles ou d’un contrôle étroit des données.

9. Conclusion

Cet article a conçu un système de facturation et de renouvellement d’abonnement capable de gérer des millions de clients. Les points clés incluent :

  • Séparer la création de facture et la collecte du paiement en deux étapes.
  • Utiliser FOR UPDATE SKIP LOCKED pour partager le travail entre instances, avec une contrainte UNIQUE comme filet de sécurité.
  • Utiliser une clé d’idempotence par tentative pour ne jamais facturer deux fois.
  • Utiliser le pattern outbox pour maintenir la cohérence entre la base de données et Kafka.
  • Combiner l’étalement des dates d’échéance, la file d’attente en base de données et la limite de débit Redis pour gérer les pics de charge.

À partir de ce cadre, vous pouvez ajouter des taxes, des codes de réduction ou une facturation à l’usage sans modifier le flux principal.

system-designspring-bootfacturationkafkaredis