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
InvoicePaidetSubscriptionCanceled.
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 utiliserdouble. - 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_duene contient que les abonnementsACTIVE. Une requête pour trouver les abonnements arrivant à échéance reste donc petite et rapide. - La colonne
versionpermet 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 :
- Créer une facture : trouver les abonnements arrivant à échéance, créer une
invoiceavec le statutOPEN, puis avancercurrent_period_endà la nouvelle période. Cette étape ne fonctionne qu’avec la base de données. - Collecter le paiement : trouver les factures
OPENavecnext_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
attemptCountet 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
PAIDsont 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 :
- Étaler les dates d’échéance :
current_period_endest 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. - 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. - 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
@Scheduledsans mécanisme de verrouillage sur plusieurs instances : cela mène à un traitement dupliqué. - Calculer les dates avec
LocalDateTimeselon le fuseau horaire du serveur : provoque des erreurs de décalage et des problèmes le 31. UtilisezInstant. Pour les périodes mensuelles, utilisezZonedDateTime.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 LOCKEDpour partager le travail entre instances, avec une contrainteUNIQUEcomme 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.