Aller au contenu

Outbox & orchestration

C’est le cœur du système. Comprenez cette page et vous comprenez Titreo E-Pay.

Un paiement multi-instruments enchaîne plusieurs appels externes risqués : débiter une carte cadeau, autoriser une CB, la capturer, prévenir le marchand. Chacun peut échouer ou être lent. Si on faisait tout dans la requête HTTP, un crash au mauvais moment laisserait un paiement à moitié fait, sans trace — par exemple carte cadeau débitée mais CB jamais autorisée.

Au lieu d’appeler les PSP directement, le système inscrit une tâche dans une table outbox, dans la même transaction que la modification de la session. Un worker lit ensuite l’outbox et exécute les tâches en arrière-plan, avec retry. Rien n’est perdu : si le process meurt, la tâche reste en base.

flowchart LR
  REQ["Requête HTTP<br/>(submit, add leg, refund…)"] -->|"transaction unique"| TX
  subgraph TX["Transaction Firebird (atomique)"]
    S["UPDATE payment_sessions / legs"]
    O["INSERT outbox (action, status=PENDING)"]
  end
  TX --> RESP["Réponse HTTP immédiate"]
  POLL["OutboxPoller (worker)"] -->|"claim + exécute"| O
  POLL --> PROV["Appels PSP / gift / webhook"]

Chaque ligne d’outbox porte une action. L’orchestrateur (process-outbox-entry.ts) route chaque action vers son handler :

Action Ce qu’elle fait Enchaîne vers
DEBIT_GIFT Débite la carte cadeau via le GiftCardProvider → leg CAPTURED ou DEBIT_FAILED webhook éventuel
AUTHORIZE_PSP Autorise (hold) la CB via le PaymentProvider → leg HELD CAPTURE_PSP (ou attend une action 3DS)
CAPTURE_PSP Capture le hold → leg CAPTURED; si tous capturés, session COMPLETED MERCHANT_WEBHOOK
RELEASE_HOLD Relâche (void) un hold PSP lors d’un rollback → leg RELEASED
CANCEL_GIFT Annule un débit gift lors d’un rollback → leg CANCELED
REFUND Rembourse un leg (PSP ou gift selon le type) → leg REFUNDED
MERCHANT_WEBHOOK Envoie un webhook signé au marchand (avec log de livraison)

L’enchaînement nominal (split-tender qui réussit)

Section intitulée « L’enchaînement nominal (split-tender qui réussit) »
sequenceDiagram
  participant W as OutboxPoller
  participant O as ProcessOutboxEntry
  participant G as GiftCardProvider
  participant P as PaymentProvider (PSP)

  Note over O: DEBIT_GIFT
  W->>O: claim + execute
  O->>G: debit(token, montant)
  G-->>O: CAPTURED (solde maj)
  Note over O: AUTHORIZE_PSP
  W->>O: claim + execute
  O->>P: authorize(montant)
  P-->>O: HELD (authId)
  O->>O: enqueue CAPTURE_PSP
  Note over O: CAPTURE_PSP
  W->>O: claim + execute
  O->>P: capture(authId)
  P-->>O: CAPTURED
  O->>O: session COMPLETED → enqueue MERCHANT_WEBHOOK

Si une étape non rattrapable échoue après que d’autres legs ont déjà été débités/tenus, l’orchestrateur déclenche un rollback : il passe la session en ROLLING_BACK et compense les legs déjà engagés — CANCEL_GIFT pour les cartes débitées, RELEASE_HOLD pour les holds PSP.

sequenceDiagram
  participant O as ProcessOutboxEntry
  participant G as GiftCardProvider
  participant P as PaymentProvider

  Note over O: DEBIT_GIFT a réussi…
  O->>P: authorize(montant)
  P-->>O: échec (carte refusée)
  O->>O: session.startRollback()
  O->>O: enqueue CANCEL_GIFT
  Note over O: CANCEL_GIFT
  O->>G: cancel(transactionId)
  G-->>O: CANCELED → session FAILED

Implémentation : api/src/infrastructure/workers/outbox-poller.ts. Point d’entrée : api/src/workers/outbox.ts (pnpm worker:outbox).

Boucle (tickOnce) :

  1. outbox.listReady(now, batchSize) — récupère les tâches prêtes (PENDING, next_attempt_at ≤ now). Lot par défaut : 10, intervalle de polling : 2 s.
  2. outbox.claim(id, workerId, now) — passe PENDING → PROCESSING atomiquement (verrou par workerId), pour qu’un seul worker traite une tâche.
  3. ProcessOutboxEntry.execute(entry) — exécute le handler de l’action.
  4. Succès → outbox.markCompleted(id). Échec → outbox.markFailed(id, error, nextAttemptAt).

En cas d’échec retriable, la tâche est replanifiée avec un backoff croissant :

1 min → 5 min → 30 min → 2 h → 12 h → 24 h

Au-delà du nombre maximal de tentatives, la tâche part en dead-letter (onDeadLetter).

Tout ne passe pas par le poller : un PSP peut aussi notifier l’API directement (ex. Stripe payment_intent.succeeded). Le use case handle-psp-webhook.ts vérifie la signature, met à jour le leg/la session, et peut enqueue un MERCHANT_WEBHOOK. Le webhook entrant et le poller convergent donc vers le même modèle d’état.

Source de vérité métier complémentaire : doc/site/content/orchestration.mdx.