Outbox & orchestration
C’est le cœur du système. Comprenez cette page et vous comprenez Titreo E-Pay.
Le problème
Section intitulée « Le problème »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.
La solution : le pattern outbox
Section intitulée « La solution : le pattern outbox »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"]
Les actions de l’outbox
Section intitulée « Les actions de l’outbox »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
La compensation (quand ça échoue)
Section intitulée « La compensation (quand ça échoue) »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
Le worker : OutboxPoller
Section intitulée « Le worker : OutboxPoller »Implémentation : api/src/infrastructure/workers/outbox-poller.ts. Point d’entrée : api/src/workers/outbox.ts
(pnpm worker:outbox).
Boucle (tickOnce) :
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.outbox.claim(id, workerId, now)— passePENDING → PROCESSINGatomiquement (verrou parworkerId), pour qu’un seul worker traite une tâche.ProcessOutboxEntry.execute(entry)— exécute le handler de l’action.- Succès →
outbox.markCompleted(id). Échec →outbox.markFailed(id, error, nextAttemptAt).
Retry avec backoff
Section intitulée « Retry avec backoff »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 hAu-delà du nombre maximal de tentatives, la tâche part en dead-letter (onDeadLetter).
Et les webhooks PSP entrants ?
Section intitulée « Et les webhooks PSP entrants ? »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.
Pour voir le code
Section intitulée « Pour voir le code »- L’orchestrateur, action par action → L’orchestrateur (outbox)
- Le worker et son point d’entrée → Workers
- Les traces complètes requête → DB → PSP → Parcours d’appel
Source de vérité métier complémentaire :
doc/site/content/orchestration.mdx.