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 table idempotency_keys est au cœur du mécanisme anti-doublon.
  • La table outbox aide à é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 :

  1. La clé n’existe pas encore : l’enregistrement réussit, on continue le traitement.
  2. La clé est déjà COMPLETED : on retourne exactement la réponse sauvegardée, sans appeler le PSP à nouveau.
  3. La clé est IN_PROGRESS : une autre requête avec la même clé s’exécute. On retourne 409 Conflict pour 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 SETNX comme 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 :

  1. Charger le fichier de règlement du PSP et le charger dans une table temporaire psp_settlements(psp_reference, amount, type).
  2. Appareiller avec les données internes via FULL OUTER JOIN.
  3. Enregistrer les écarts dans une table recon_breaks pour 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’enregistrement CAPTURED. Généralement une transaction UNKNOWN ou 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 + EXPIRE par 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 paymentId pour 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 503 avec Retry-After, plutôt que de laisser le pool de threads s’épuiser. Un client relancé avec la même Idempotency-Key sera sûr grâce à la section 5.
  • Nettoyer la table idempotency_keys régulièrement (garder 24–72 heures) et la partitionner par date, évitant que la table se gonfle et ralentisse les INSERT.

10. Erreurs courantes

  • Utiliser double/float pour l’argent. Utiliser long en unité minimale, ou BigDecimal quand nécessaire pour le taux de change.
  • Traiter le timeout comme un échec. Un timeout signifie inconnu, il faut donc mettre UNKNOWN puis 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 @Transactional et 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.

system-designpaymentidempotencyspring-bootpostgresql