Conception d'un système de paiement : idempotence, réconciliation et remboursement
1. Aperçu général
Le problème de la prise de paiement diffère des tâches CRUD ordinaires sur un point : une erreur et de l’argent réel disparaît. Une relance du client peut débiter deux fois le client. Une passerelle de paiement (PSP) peut expirer bien que la transaction soit réussie. Deux requêtes de remboursement s’exécutant en parallèle peuvent rembourser au-delà du montant collecté.
Cet article présente une conception concrète comprenant les exigences, le modèle de données, l’API et les flux de traitement. Ensuite, il résout trois points difficiles : l’idempotence, la réconciliation et le remboursement. Après sa lecture, vous pouvez construire vous-même le cœur du système avec Spring Boot 3 et PostgreSQL, accompagné de tests prouvant que le système ne rembourse jamais au-delà du montant collecté en cas de conflit.
2. Exigences du problème
2.1. Exigences fonctionnelles
- Le commerçant crée un paiement pour une
Order. Le système appelle le PSP pour débiter la carte ou le portefeuille. - Le commerçant rembourse intégralement ou partiellement. Le montant total remboursé ne dépasse pas le montant collecté.
- Quotidiennement, le système réconcilie les données internes avec le fichier de règlement du PSP.
- Le système émet des événements (
PaymentCaptured,RefundSucceeded…) pour les autres services tels que Order et Accounting.
2.2. Échelle et contraintes
- Moyenne 200 transactions/seconde. Pic de flash sale environ 5 000 transactions/seconde pendant quelques minutes.
- Ne jamais débiter deux fois pour la même intention de paiement. C’est une contrainte matérielle.
- Préférer la cohérence à la disponibilité : mieux vaut retourner une erreur au client que d’enregistrer un montant incorrect.
- Le PSP est un système externe. Il peut être lent, expirer ou envoyer des webhooks tardivement ou en doublon.
3. Modèle de données
L’argent est toujours stocké en unité minimale (VND en dong, USD en cent) avec le type BIGINT. Ne pas utiliser double.
CREATE TABLE payments (
id UUID PRIMARY KEY,
merchant_id TEXT NOT NULL,
order_id TEXT NOT NULL,
amount BIGINT NOT NULL CHECK (amount > 0),
currency CHAR(3) NOT NULL,
status TEXT NOT NULL, -- CREATED, PROCESSING, CAPTURED, FAILED, UNKNOWN
refunded_amount BIGINT NOT NULL DEFAULT 0,
psp_reference TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (refunded_amount <= amount)
);
CREATE TABLE refunds (
id UUID PRIMARY KEY,
payment_id UUID NOT NULL REFERENCES payments(id),
amount BIGINT NOT NULL CHECK (amount > 0),
status TEXT NOT NULL, -- PENDING, SUCCEEDED, FAILED
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE idempotency_keys (
merchant_id TEXT NOT NULL,
idem_key TEXT NOT NULL,
request_hash TEXT NOT NULL,
status TEXT NOT NULL, -- IN_PROGRESS, COMPLETED
response_code INT,
response_body TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (merchant_id, idem_key)
);
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
topic TEXT NOT NULL,
payload JSONB NOT NULL,
sent_at TIMESTAMPTZ
);
Quelques points à noter :
CHECK (refunded_amount <= amount)est le filet de sécurité final au niveau de la base de données. Si le code est bogué, la base de données bloque quand même.- La clé primaire
(merchant_id, idem_key)de la tableidempotency_keysest au cœur du mécanisme anti-doublon. - La table
outboxaide à émettre des événements vers Kafka sans avoir besoin d’une transaction distribuée (section 6).
4. Conception de l’API
POST /v1/payments
Headers: Idempotency-Key: 7f3c1e2a-...
Body: { "orderId": "ORD-1001", "amount": 250000, "currency": "VND", "source": "tok_visa" }
→ 201 { "id": "...", "status": "CAPTURED" }
POST /v1/payments/{id}/refunds
Headers: Idempotency-Key: 9a8b...
Body: { "amount": 100000 }
→ 202 { "refundId": "...", "status": "PENDING" }
GET /v1/payments/{id}
Chaque API qui change d’état doit avoir un Idempotency-Key généré par le client. Le client le renvoie identique lors d’une relance. Le remboursement retourne 202 Accepted car le PSP traite le remboursement de façon asynchrone.
5. Idempotence : éviter de débiter deux fois
5.1. Idée
Lors de la réception d’une requête, on enregistre d’abord la clé dans la base de données avant de rien faire. Il y a trois possibilités :
- La clé n’existe pas encore : l’enregistrement réussit, on continue le traitement.
- La clé est déjà
COMPLETED: on retourne exactement la réponse sauvegardée, sans appeler le PSP à nouveau. - La clé est
IN_PROGRESS: une autre requête avec la même clé s’exécute. On retourne409 Conflictpour que le client réessaye plus tard.
On enregistre aussi un hash du body. Si le client réutilise la clé avec un body différent (par exemple, amount différent), c’est une erreur du côté client et on retourne 422.
5.2. Mise en œuvre
Dépendances à utiliser :
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
@Service
public class IdempotencyService {
private final JdbcTemplate jdbc;
public IdempotencyService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
public record StoredResponse(int code, String body) {}
public Optional<StoredResponse> begin(String merchantId, String key, String requestHash) {
int inserted = jdbc.update("""
INSERT INTO idempotency_keys (merchant_id, idem_key, request_hash, status)
VALUES (?, ?, ?, 'IN_PROGRESS')
ON CONFLICT (merchant_id, idem_key) DO NOTHING
""", merchantId, key, requestHash);
if (inserted == 1) {
return Optional.empty(); // nouvelle requête, autorisée à traiter
}
Map<String, Object> row = jdbc.queryForMap("""
SELECT request_hash, status, response_code, response_body
FROM idempotency_keys WHERE merchant_id = ? AND idem_key = ?
""", merchantId, key);
if (!requestHash.equals(row.get("request_hash"))) {
throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "Key reused with different body");
}
if ("IN_PROGRESS".equals(row.get("status"))) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Request is being processed");
}
return Optional.of(new StoredResponse((Integer) row.get("response_code"), (String) row.get("response_body")));
}
public void complete(String merchantId, String key, int code, String body) {
jdbc.update("""
UPDATE idempotency_keys SET status = 'COMPLETED', response_code = ?, response_body = ?
WHERE merchant_id = ? AND idem_key = ?
""", code, body, merchantId, key);
}
}
ON CONFLICT DO NOTHING transforme l’opération « vérifier puis enregistrer » en une seule instruction atomique. Deux requêtes avec la même clé arrivant simultanément signifient que seule une requête obtient inserted == 1. L’autre requête tombe dans la branche de lecture et reçoit 409. Pas de condition de course du type « les deux voient la clé n’existe pas encore ».
Note : Ne pas utiliser uniquement Redis
SETNXcomme source de vérité pour l’idempotence des paiements. Un basculement Redis ou une clé évincée et vous perdez la trace, et l’argent est débité deux fois. Redis ne doit être qu’une couche de cache en frontal, tandis que la base de données est l’arbitre.
5.3. Idempotence côté PSP aussi
Votre requête est protégée, mais votre appel au PSP peut aussi être relancé. La plupart des PSP supportent une clé d’idempotence. Transmettez l’ID du paiement lui-même comme clé :
PspResult result = pspClient.charge(new ChargeRequest(
payment.id().toString(), // clé d'idempotence envoyée au PSP
payment.amount(),
payment.currency(),
sourceToken));
Quand l’appel au PSP expire, vous ne savez pas si l’argent a été débité ou non. Ne marquez pas FAILED. Mettez le statut à UNKNOWN, puis lancez un job qui redemande au PSP avec payments.id. Marquer FAILED puis laisser le client réessayer avec une nouvelle clé, c’est le chemin classique vers le double débit.
6. Cohérence entre la base de données et Kafka : Transactional Outbox
Après avoir collecté l’argent, il faut mettre à jour payments et émettre l’événement PaymentCaptured. Si l’écriture en base réussit mais l’envoi à Kafka échoue (ou l’inverse), le service Order se désynchronise. La solution est d’enregistrer l’événement dans la table outbox dans la même transaction :
@Transactional
public void markCaptured(UUID paymentId, String pspReference) {
jdbc.update("UPDATE payments SET status = 'CAPTURED', psp_reference = ? WHERE id = ? AND status IN ('PROCESSING','UNKNOWN')",
pspReference, paymentId);
jdbc.update("INSERT INTO outbox (topic, payload) VALUES ('payment-events', ?::jsonb)",
"""
{"type":"PaymentCaptured","paymentId":"%s"}""".formatted(paymentId));
}
Un relais (job périodique ou Debezium CDC) lit les lignes où sent_at IS NULL, envoie vers Kafka puis met à jour sent_at. Le relais peut envoyer en doublon, donc le consumer doit être idempotent, par exemple en gardant un historique de paymentId déjà traité. La condition status IN (...) dans l’UPDATE aide les webhooks en doublon du PSP à ne pas écraser un état final différent.
7. Remboursement et conflit simultané
7.1. Le problème
Paiement de 100 000 dong. Le support client et un job automatique envoient tous deux une demande de remboursement de 60 000 dong. Si le code suit le pattern « lire refunded_amount, vérifier, puis enregistrer », les deux voient 0 + 60 000 <= 100 000 et sont approuvés. Le résultat est un remboursement de 120 000 dong.
7.2. Solution : mise à jour conditionnelle
Fusionner la vérification et la mise à jour en une seule instruction UPDATE avec condition :
@Service
public class RefundService {
private final JdbcTemplate jdbc;
public RefundService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional
public UUID requestRefund(UUID paymentId, long amount) {
int reserved = jdbc.update("""
UPDATE payments SET refunded_amount = refunded_amount + ?
WHERE id = ? AND status = 'CAPTURED' AND refunded_amount + ? <= amount
""", amount, paymentId, amount);
if (reserved == 0) {
throw new IllegalStateException("Refund exceeds refundable amount or payment not captured");
}
UUID refundId = UUID.randomUUID();
jdbc.update("INSERT INTO refunds (id, payment_id, amount, status) VALUES (?, ?, ?, 'PENDING')",
refundId, paymentId, amount);
jdbc.update("INSERT INTO outbox (topic, payload) VALUES ('refund-commands', ?::jsonb)",
"""
{"refundId":"%s","paymentId":"%s","amount":%d}""".formatted(refundId, paymentId, amount));
return refundId;
}
}
PostgreSQL verrouille la ligne payments lors du UPDATE. La deuxième transaction doit attendre le commit de la première. Elle réevalue ensuite la condition WHERE sur les nouvelles données, voit que 60 000 + 60 000 > 100 000 et obtient reserved == 0. C’est le comportement par défaut au niveau READ COMMITTED, pas besoin de SERIALIZABLE.
On « réserve » le montant en premier, puis on appelle le PSP de façon asynchrone via outbox. Si le PSP refuse le remboursement, le consumer restitue la réserve :
UPDATE payments SET refunded_amount = refunded_amount - :amount WHERE id = :paymentId;
UPDATE refunds SET status = 'FAILED' WHERE id = :refundId AND status = 'PENDING';
Ces deux instructions doivent être dans la même transaction. La condition status = 'PENDING' assure qu’un webhook d’échec envoyé en doublon ne restitue pas la réserve deux fois. Pour être plus strict, vous exécutez seulement la première instruction quand la deuxième met à jour exactement une ligne.
7.3. Test de conflit
Dépendances de test :
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class RefundServiceTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired RefundService refundService;
@Autowired JdbcTemplate jdbc;
@Test
void concurrentRefunds_neverExceedCapturedAmount() throws Exception {
UUID paymentId = UUID.randomUUID();
jdbc.update("""
INSERT INTO payments (id, merchant_id, order_id, amount, currency, status)
VALUES (?, 'shop-1', 'ORD-1001', 100000, 'VND', 'CAPTURED')
""", paymentId);
Callable<Boolean> refund60k = () -> {
try {
refundService.requestRefund(paymentId, 60_000);
return true;
} catch (IllegalStateException e) {
return false;
}
};
List<Boolean> results;
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
results = executor.invokeAll(List.of(refund60k, refund60k))
.stream().map(Future::resultNow).toList();
}
assertThat(results).containsExactlyInAnyOrder(true, false);
Long refunded = jdbc.queryForObject(
"SELECT refunded_amount FROM payments WHERE id = ?", Long.class, paymentId);
assertThat(refunded).isEqualTo(60_000L);
}
}
Le test suppose que le schéma de la section 3 est chargé via src/test/resources/schema.sql (avec spring.sql.init.mode=always) ou via Flyway. Deux virtual threads envoient un remboursement simultanément. Le résultat attendu est toujours stable :
results = [true, false] (ordre quelconque)
refunded_amount = 60000
Essayez de remplacer l’UPDATE par une lecture puis une comparaison en Java, vous verrez que le test échoue parfois. C’est exactement la condition de course que cette conception élimine.
8. Réconciliation
Peu importe votre prudence, les données internes et le PSP peuvent diverger. Les webhooks peuvent être perdus, il peut rester des transactions UNKNOWN non résolues, ou le PSP peut rembourser lui-même due à une opposition. La réconciliation est la dernière ligne de défense.
Flux quotidien :
- Charger le fichier de règlement du PSP et le charger dans une table temporaire
psp_settlements(psp_reference, amount, type). - Appareiller avec les données internes via
FULL OUTER JOIN. - Enregistrer les écarts dans une table
recon_breakspour traitement automatique ou manuel.
INSERT INTO recon_breaks (psp_reference, internal_amount, psp_amount, reason)
SELECT COALESCE(p.psp_reference, s.psp_reference),
p.amount,
s.amount,
CASE
WHEN p.psp_reference IS NULL THEN 'MISSING_INTERNAL'
WHEN s.psp_reference IS NULL THEN 'MISSING_AT_PSP'
ELSE 'AMOUNT_MISMATCH'
END
FROM (SELECT * FROM payments
WHERE status = 'CAPTURED' AND created_at >= :day AND created_at < :day + INTERVAL '1 day') p
FULL OUTER JOIN (SELECT * FROM psp_settlements WHERE type = 'CHARGE') s
ON p.psp_reference = s.psp_reference
WHERE p.psp_reference IS NULL
OR s.psp_reference IS NULL
OR p.amount <> s.amount;
Signification de chaque type d’écart :
MISSING_INTERNAL: le PSP a collecté l’argent mais vous n’avez pas d’enregistrementCAPTURED. Généralement une transactionUNKNOWNou un webhook perdu. Traiter en mettant à jour le statut, ou en remboursant si la commande a été annulée.MISSING_AT_PSP: vous avez enregistré la collecte mais le PSP n’en a pas. C’est grave car vous avez peut-être expédié la marchandise sans recevoir l’argent.AMOUNT_MISMATCH: généralement dû à des frais, taux de change, ou arrondissement bugué.
Note : Faites attention à la fenêtre de temps. Une transaction à 23:59 peut être dans le fichier de règlement du jour suivant. Effectuez la réconciliation avec un délai T+1 ou T+2, et ne remontez un écart à un humain qu’après qu’il persiste deux exécutions consécutives.
9. Pics de charge
Pendant un flash sale, la charge augmente 25 fois. Quelques techniques aident le système à supporter :
- Limitation de débit par commerçant avec Redis (
INCR+EXPIREpar fenêtre d’une seconde). Cela protège le système d’un commerçant « bavard ». Ici Redis sert juste pour la limitation, pas pour décider la correction d’une transaction. - Séparer la partie synchrone et asynchrone. Le chemin synchrone contient uniquement enregistrement de la clé d’idempotence, création du paiement et appel au PSP pour autoriser. Remboursement, envoi d’emails et mise à jour de la comptabilité passent par outbox → Kafka. Partitionner Kafka par
paymentIdpour préserver l’ordre des événements pour chaque paiement. - Disjoncteur et timeouts courts pour les appels PSP (par exemple Resilience4j). Quand le PSP ralentit, échouer vite et retourner
503avecRetry-After, plutôt que de laisser le pool de threads s’épuiser. Un client relancé avec la mêmeIdempotency-Keysera sûr grâce à la section 5. - Nettoyer la table
idempotency_keysrégulièrement (garder 24–72 heures) et la partitionner par date, évitant que la table se gonfle et ralentisse lesINSERT.
10. Erreurs courantes
- Utiliser
double/floatpour l’argent. Utiliserlongen unité minimale, ouBigDecimalquand nécessaire pour le taux de change. - Traiter le timeout comme un échec. Un timeout signifie inconnu, il faut donc mettre
UNKNOWNpuis redemander au PSP. - Générer la clé d’idempotence côté serveur. Elle doit venir du client et rester identique à travers les relances.
- Vérifier le solde de remboursement en Java avant l’
UPDATE. Laisser la base de données le faire en une instruction. - Envoyer Kafka dans une
@Transactionalet espérer qu’il rollback avec la base. Il ne rollback pas. Utiliser outbox. - Ignorer la réconciliation parce que « le code est correct ». Les systèmes externes ne sont pas sous votre contrôle.
11. Conclusion
Cet article a conçu le cœur d’un système de paiement basé sur trois principes. D’abord, l’idempotence est assurée par une contrainte unique en base de données, tant pour votre API que pour les appels PSP. Deuxièmement, les conflits de remboursement sont résolus par mise à jour conditionnelle plus contrainte CHECK, avec un test concurrentiel prouvant l’absence de remboursement excessif. Troisièmement, la cohérence avec l’extérieur repose sur transactional outbox et réconciliation quotidienne.
Le fil rouge des trois est de pousser les invariants critiques vers la base de données, où ils s’exécutent atomiquement. De plus, assumez toujours que le réseau, le PSP, et votre code peuvent échouer, et préparez des mécanismes de détection et de correction.