L’orchestrateur (outbox)
À quoi ça sert
Section intitulée « À quoi ça sert »Un paiement Titreo E-Pay n’est jamais exécuté « en direct » pendant la requête HTTP du marchand. Quand un client paie 100 € avec 30 € de carte cadeau + 70 € de carte bancaire, on ne peut pas se permettre de débiter la carte cadeau puis de planter au milieu de l’autorisation PSP : il faut pouvoir rejouer, reprendre et annuler proprement chaque étape. C’est exactement le rôle de l’outbox : chaque opération externe (débit Titreo, autorisation/capture PSP, remboursement, webhook marchand) est d’abord écrite en base comme une ligne de travail à faire, dans la même transaction que la modification métier. Un worker la lit ensuite et l’exécute.
ProcessOutboxEntry est le moteur qui exécute une de ces lignes. Il joue trois rôles :
- Routeur d’actions : selon le champ
actionde la ligne, il appelle le bon handler. - Exécuteur transactionnel : chaque handler tourne dans une unit of work (
uow.run), met à jour la session et ses legs, et persiste. - Orchestrateur du chemin : un handler qui réussit enqueue l’action suivante (ex. une autorisation PSP réussie enqueue la capture), et un handler qui échoue déclenche la compensation (annulation des cartes cadeaux déjà débitées).
Fichiers couverts par cette page :
api/src/application/workers/process-outbox-entry.ts— le routeur + tous les handlersapi/src/application/workers/cleanup-expired-sessions.ts— le balayage des sessions expirées (enqueue de compensation)
Le routeur : ProcessOutboxEntry.execute
Section intitulée « Le routeur : ProcessOutboxEntry.execute »Le point d’entrée est execute(entry). C’est un simple switch sur entry.action qui délègue à une méthode privée. Toute action inconnue renvoie un échec UNKNOWN_ACTION.
async execute(entry: OutboxEntry): Promise<ProcessResult> { switch (entry.action) { case 'DEBIT_GIFT': return this.debitGift(entry) case 'CANCEL_GIFT': return this.cancelGift(entry) case 'AUTHORIZE_PSP': return this.authorizePsp(entry) case 'CAPTURE_PSP': return this.capturePsp(entry) case 'RELEASE_HOLD': return this.releasePsp(entry) case 'REFUND': return this.refund(entry) case 'MERCHANT_WEBHOOK': return this.merchantWebhook(entry) default: return { status: 'FAILED', errorCode: 'UNKNOWN_ACTION', errorMessage: entry.action } }}Le retour est typé ProcessResult :
export type ProcessResult = | { status: 'COMPLETED' } | { status: 'FAILED'; errorCode: string; errorMessage: string }C’est ce résultat que le poller interprète : COMPLETED ⇒ la ligne est marquée terminée ; FAILED ⇒ retry plus tard (ou dead-letter si attempts >= maxAttempts).
Helper commun : requireSessionLeg
Section intitulée « Helper commun : requireSessionLeg »Tous les handlers « leg » passent par requireSessionLeg(sessions, entry) qui charge la session via entry.sessionId, exige un entry.legId, retrouve le leg, et lève une ValidationError si l’un manque. Cette ValidationError n’est pas attrapée dans le handler : elle remonte au poller, qui la transforme en FAILED/PROCESSOR_THROW (retriable).
Tableau des actions
Section intitulée « Tableau des actions »Action (OutboxAction) |
Handler | Effet externe | Mise à jour domaine (succès) | Enchaîne vers |
|---|---|---|---|---|
DEBIT_GIFT |
debitGift |
giftRegistry.get(...).debit(...) (débit Titreo) |
leg.markGiftDebited(...) (leg ⇒ CAPTURED) |
(rien — chaque leg est indépendant) |
AUTHORIZE_PSP |
authorizePsp |
pspRegistry.get(...).authorize(...) |
leg.markPspHeld(...) (⇒ HELD) ou markPspRequiresAction(...) (3-D Secure) |
enqueue CAPTURE_PSP (si HELD) |
CAPTURE_PSP |
capturePsp |
provider.capture(...) |
leg.markPspCaptured(...) (⇒ CAPTURED) ; si tous les legs CAPTURED ⇒ session.markCompleted |
enqueue MERCHANT_WEBHOOK via maybeEnqueueWebhook |
CANCEL_GIFT |
cancelGift |
provider.cancel(...) (annulation Titreo) |
leg.markGiftCanceled(...) + session.recomputeStatusFromLegs |
(rien) |
RELEASE_HOLD |
releasePsp |
provider.void(...) (libère l’autorisation) |
leg.markPspReleased(...) + recomputeStatusFromLegs |
(rien) |
REFUND |
refund |
provider.refund(...) (PSP ou gift) |
leg.markRefunded(...) + recomputeStatusFromLegs |
(rien) |
MERCHANT_WEBHOOK |
merchantWebhook |
webhookSender.send(...) (POST signé HMAC) |
journalise dans webhookDeliveries (best-effort) |
(rien) |
Où les actions « racine » sont enqueuées
Section intitulée « Où les actions « racine » sont enqueuées »ProcessOutboxEntry n’est pas l’origine de la chaîne. Les use cases applicatifs enqueuent la première action :
DEBIT_GIFTest enqueué parapi/src/application/sessions/add-gift-leg.tsAUTHORIZE_PSPest enqueué parapi/src/application/sessions/submit-session.tsREFUNDest enqueué par le use case de remboursement
ProcessOutboxEntry ne fait qu’enqueuer les suites (CAPTURE_PSP, CANCEL_GIFT, MERCHANT_WEBHOOK).
Chemin nominal : split-tender qui réussit
Section intitulée « Chemin nominal : split-tender qui réussit »Scénario : carte cadeau partielle + carte bancaire, tout passe. Noter que DEBIT_GIFT (leg cadeau) et AUTHORIZE_PSP (leg PSP) sont deux lignes outbox indépendantes, traitées chacune à son tour par le poller. C’est la capture du dernier leg qui fait basculer la session en COMPLETED.
sequenceDiagram autonumber participant P as "OutboxPoller" participant E as "ProcessOutboxEntry" participant G as "GiftProvider (Titreo)" participant S as "PSP (Stripe/PayPlug)" participant DB as "Session/Legs (DB)" Note over P,DB: Ligne 1 — DEBIT_GIFT (leg cadeau) P->>E: execute(DEBIT_GIFT) E->>G: debit(token, amount, idempotencyKey) G-->>E: CAPTURED E->>DB: leg.markGiftDebited() ⇒ leg CAPTURED E-->>P: COMPLETED Note over P,DB: Ligne 2 — AUTHORIZE_PSP (leg PSP) P->>E: execute(AUTHORIZE_PSP) E->>S: authorize(amount, paymentMethod, idempotencyKey) S-->>E: HELD (authId) E->>DB: leg.markPspHeld() ⇒ leg HELD E->>DB: enqueue CAPTURE_PSP (idempotencyKey capture:legId) E-->>P: COMPLETED Note over P,DB: Ligne 3 — CAPTURE_PSP P->>E: execute(CAPTURE_PSP) E->>S: capture(authId, idempotencyKey) S-->>E: CAPTURED E->>DB: leg.markPspCaptured() ⇒ leg CAPTURED Note right of DB: tous les legs CAPTURED ⇒ session.markCompleted() ⇒ COMPLETED E->>DB: maybeEnqueueWebhook() ⇒ enqueue MERCHANT_WEBHOOK E-->>P: COMPLETED Note over P,DB: Ligne 4 — MERCHANT_WEBHOOK P->>E: execute(MERCHANT_WEBHOOK) E->>E: webhookSender.send(url, body, signature) E-->>P: COMPLETED
Extrait clé de la complétion (dans capturePsp) :
if (result.status === 'CAPTURED') { leg.markPspCaptured(now) // bascule la session en COMPLETED uniquement quand TOUS les legs sont capturés if (session.status === 'PROCESSING' && session.legs.every((l) => l.status === 'CAPTURED')) { session.markCompleted(now) } await sessions.save(session) await this.maybeEnqueueWebhook(session, leg.id) // ⇒ enqueue MERCHANT_WEBHOOK si terminal return { status: 'COMPLETED' }}maybeEnqueueWebhook n’enqueue un webhook que si eventForStatus(session.status) renvoie un événement (statut terminal : COMPLETED, FAILED, EXPIRED, FULLY_REFUNDED, PARTIALLY_REFUNDED) et que le marchand a une webhookUrl. Voir api/src/application/webhooks/merchant-webhook.ts (table TERMINAL_EVENTS).
Compensation : un échec PSP annule les cadeaux déjà débités
Section intitulée « Compensation : un échec PSP annule les cadeaux déjà débités »Si l’autorisation ou la capture PSP échoue alors qu’une carte cadeau a déjà été débitée, on ne peut pas laisser le client lésé. Le handler bascule la session en rollback et enqueue l’annulation de chaque leg cadeau capturé.
Mécanique commune (authorizePsp et capturePsp partagent ce code en cas d’échec) :
leg.markPspHoldFailed({ errorCode, errorMessage, now }) // (ou markPspCaptureFailed)session.startRollback(now) // session ⇒ ROLLING_BACKawait this.compensateCapturedGifts(session, outbox, now) // enqueue 1 CANCEL_GIFT par leg cadeau CAPTUREDawait sessions.save(session)return failed(result.errorCode, result.errorMessage)compensateCapturedGifts parcourt les legs, et pour chaque leg gift_card en statut CAPTURED : il appelle l.startGiftCancel(now) (leg ⇒ CANCELING) puis enqueue une ligne CANCEL_GIFT avec idempotencyKey = cancel:${legId}.
flowchart TD
A["CAPTURE_PSP ou AUTHORIZE_PSP échoue"] --> B["leg.markPsp...Failed()"]
B --> C["session.startRollback() ⇒ ROLLING_BACK"]
C --> D["compensateCapturedGifts()"]
D --> E{"pour chaque leg gift_card CAPTURED"}
E -->|"oui"| F["leg.startGiftCancel() ⇒ CANCELING"]
F --> G["enqueue CANCEL_GIFT (cancel:legId)"]
G --> H["return FAILED (la ligne PSP est en échec)"]
E -->|"aucun"| H
G -.->|"plus tard, poller"| I["execute(CANCEL_GIFT)"]
I --> J["provider.cancel(...) ⇒ CANCELED"]
J --> K["leg.markGiftCanceled() + recomputeStatusFromLegs()"]
cancelGift ne fait le travail que si le leg est en CANCELING (sinon COMPLETED). En cas de succès : leg.markGiftCanceled(now) puis session.recomputeStatusFromLegs(now) — le statut de la session est dérivé des statuts de legs (par deriveSessionStatus), il n’est jamais positionné « à la main » lors d’une compensation.
Erreurs retriables vs non-retriables
Section intitulée « Erreurs retriables vs non-retriables »ProcessOutboxEntry ne gère pas lui-même les retries : il renvoie toujours COMPLETED ou FAILED. C’est le poller qui décide quoi faire d’un FAILED. Distinction pratique :
| Type d’issue | Comment elle apparaît | Comportement |
|---|---|---|
| Succès | handler renvoie { status: 'COMPLETED' } |
poller ⇒ markCompleted, fin |
| Échec « métier » (provider refuse, payload invalide…) | handler renvoie failed(code, msg) |
poller ⇒ markFailed + nextAttemptAt selon le backoff ; rejoué plus tard |
Exception non gérée (ex. requireSessionLeg lève ValidationError, bug, timeout réseau) |
la méthode throw |
le poller attrape dans handle() et fabrique { FAILED, errorCode: 'PROCESSOR_THROW' } ⇒ retriable comme ci-dessus |
| Épuisement des tentatives | attempts >= entry.maxAttempts côté poller |
dead-letter : markFailed("DEAD_LETTERED: …", farFuture) + onDeadLetter |
Il n’existe pas, à ce stade, de notion de « erreur définitivement non-retriable » qui couperait court avant maxAttempts : tout FAILED est retriable jusqu’à épuisement, puis dead-letter. La cadence de retry est prescrite par DEFAULT_BACKOFF_SCHEDULE_MS du poller (1 min, 5 min, 30 min, 2 h, 12 h, 24 h). Voir Workers et Idempotence.
Comment les statuts session / leg sont mis à jour
Section intitulée « Comment les statuts session / leg sont mis à jour »Deux mécanismes coexistent, et c’est important de ne pas les confondre :
-
Transitions explicites de leg — chaque handler appelle une méthode de transition sur le leg (
markGiftDebited,markPspHeld,markPspCaptured,markGiftCanceled,markPspReleased,markRefunded, et les variantes...Failed). Ces méthodes vivent dans le domaine et valident la transition. -
Statut de session — deux voies :
- Voie explicite :
session.markCompleted(now)(uniquement depuisPROCESSINGquand tous les legs sontCAPTURED) etsession.startRollback(now)(versROLLING_BACK, depuisPROCESSINGouCOLLECTING). - Voie dérivée :
session.recomputeStatusFromLegs(now)recalcule le statut à partir de l’ensemble des statuts de legs (deriveSessionStatus). Utilisée aprèscancelGift,releasePsp,refund— là où l’état terminal dépend de la combinaison des legs (ex. partiellement vs totalement remboursé).
- Voie explicite :
stateDiagram-v2 [*] --> CREATED CREATED --> COLLECTING : ajout de legs COLLECTING --> PROCESSING : submit PROCESSING --> COMPLETED : tous legs CAPTURED (markCompleted) PROCESSING --> ROLLING_BACK : échec PSP (startRollback) COLLECTING --> ROLLING_BACK : startRollback ROLLING_BACK --> FAILED : compensation terminée (recompute / markFailed) PROCESSING --> EXPIRED : timeout (cleanup) COLLECTING --> EXPIRED : timeout (cleanup) COMPLETED --> PARTIALLY_REFUNDED : refund partiel (recompute) COMPLETED --> FULLY_REFUNDED : refund total (recompute)
Le webhook marchand (merchantWebhook)
Section intitulée « Le webhook marchand (merchantWebhook) »Cette action est un peu à part : elle n’a pas de leg « métier » à muter. Elle lit url, body, signature, eventType, timestamp dans entry.payload (préparés à l’enqueue par enqueueMerchantWebhook), envoie le POST via webhookSender.send(...), et journalise best-effort dans webhookDeliveries (un échec de journalisation ne bloque jamais le traitement — .catch(() => {})).
- Si l’envoi renvoie
OK⇒{ status: 'COMPLETED' }. - Sinon ⇒
failed("HTTP_<status>" | "HTTP_NETWORK", errorMessage)⇒ retry/backoff comme toute autre ligne. Le webhook bénéficie donc gratuitement des retries de l’outbox.
La signature HMAC (t=<ts>,v1=<hmac sha256>) est calculée à l’enqueue dans signMerchantPayload. Le corps JSON suit MerchantWebhookPayload (event, session_id, status, total_amount, reference, occurred_at, …).
cleanup-expired-sessions.ts : compensation des sessions abandonnées
Section intitulée « cleanup-expired-sessions.ts : compensation des sessions abandonnées »Un client peut démarrer un paiement puis disparaître : carte cadeau débitée, autorisation PSP posée, mais jamais finalisé. CleanupExpiredSessions est un second worker (lancé périodiquement, comme le poller) qui ferme proprement ces sessions expirées et libère les fonds immobilisés.
execute(opts) tourne dans une seule uow.run :
sessions.listExpiredActive(now, batchSize)— récupère un lot de sessions actives dontexpiresAtest dépassé (batchSizepar défaut : 50).- Pour chaque session, pour chaque leg :
- leg
gift_cardenCAPTURED⇒leg.startGiftCancel(now)puis enqueueCANCEL_GIFT(reason: 'session_expired',idempotencyKey = cancel:legId). - leg
pspenHELD⇒leg.startPspRelease(now)puis enqueueRELEASE_HOLD(reason: 'session_expired',idempotencyKey = release:legId).
- leg
session.expire(now)⇒ session enEXPIRED, puissessions.save(session).- Si
eventForStatus(session.status)est terminal et que le marchand a unewebhookUrl⇒enqueueMerchantWebhook(...)(événementsession.expired).
Retour : { scanned, expired, enqueuedCancelGifts, enqueuedReleases }.
flowchart TD
A["CleanupExpiredSessions.execute()"] --> B["listExpiredActive(now, batchSize)"]
B --> C{"pour chaque session expirée"}
C --> D{"pour chaque leg"}
D -->|"gift_card CAPTURED"| E["startGiftCancel() + enqueue CANCEL_GIFT"]
D -->|"psp HELD"| F["startPspRelease() + enqueue RELEASE_HOLD"]
D --> G["session.expire() ⇒ EXPIRED + save"]
G --> H{"statut terminal + webhookUrl ?"}
H -->|"oui"| I["enqueue MERCHANT_WEBHOOK (session.expired)"]
Récapitulatif fichiers
Section intitulée « Récapitulatif fichiers »| Fichier | Rôle | Exports clés |
|---|---|---|
api/src/application/workers/process-outbox-entry.ts |
Routeur + handlers de toutes les actions outbox ; compensation et enchaînement | classe ProcessOutboxEntry (execute) ; type ProcessResult |
api/src/application/workers/cleanup-expired-sessions.ts |
Balaye les sessions expirées, enqueue les compensations, marque EXPIRED |
classe CleanupExpiredSessions (execute) ; types CleanupResult, CleanupExpiredSessionsOptions |
api/src/application/webhooks/merchant-webhook.ts |
Construit/signe le payload marchand et enqueue MERCHANT_WEBHOOK ; mappe statut ⇒ événement |
enqueueMerchantWebhook, eventForStatus, signMerchantPayload, TERMINAL_EVENTS |
api/src/application/ports/outbox-repository.ts |
Contrat du dépôt outbox + types d’actions | types OutboxAction, OutboxEntry, OutboxRepository |
api/src/infrastructure/workers/outbox-poller.ts |
Boucle qui réclame et exécute les lignes, gère retries/backoff/dead-letter | classe OutboxPoller (détaillée dans Workers) |
Un chemin d’appel commenté
Section intitulée « Un chemin d’appel commenté »add-gift-leg / submit-session enqueue DEBIT_GIFT / AUTHORIZE_PSP (use cases applicatifs) └─ OutboxPoller.tickOnce() listReady → claim(atomique) → handle() └─ ProcessOutboxEntry.execute(entry) └─ switch(entry.action) → authorizePsp(entry) ├─ requireSessionLeg() charge session + leg ├─ provider.authorize(...) appel PSP (clé d’idempotence) ├─ leg.markPspHeld(now) transition domaine (leg ⇒ HELD) └─ outbox.enqueue(CAPTURE_PSP) enchaîne l’étape suivante ↩ retourne COMPLETED → poller.markCompleted() (ou FAILED → markFailed + backoff, sinon dead-letter)Pour aller plus loin
Section intitulée « Pour aller plus loin »- Outbox & orchestration — le pattern, le « pourquoi », vue d’ensemble
- Workers — le poller
OutboxPolleret la planification (retries, backoff, dead-letter) - Machines à états — cycles de vie session + legs
- Idempotence — clés et garanties de rejeu
- Couche Application — les use cases qui enqueuent les actions racines