Aller au contenu

L’orchestrateur (outbox)

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 :

  1. Routeur d’actions : selon le champ action de la ligne, il appelle le bon handler.
  2. Exécuteur transactionnel : chaque handler tourne dans une unit of work (uow.run), met à jour la session et ses legs, et persiste.
  3. 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 handlers
  • api/src/application/workers/cleanup-expired-sessions.ts — le balayage des sessions expirées (enqueue de compensation)

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.

api/src/application/workers/process-outbox-entry.ts
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).

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).


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 CAPTUREDsession.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)

ProcessOutboxEntry n’est pas l’origine de la chaîne. Les use cases applicatifs enqueuent la première action :

  • DEBIT_GIFT est enqueué par api/src/application/sessions/add-gift-leg.ts
  • AUTHORIZE_PSP est enqueué par api/src/application/sessions/submit-session.ts
  • REFUND est enqueué par le use case de remboursement

ProcessOutboxEntry ne fait qu’enqueuer les suites (CAPTURE_PSP, CANCEL_GIFT, MERCHANT_WEBHOOK).


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_BACK
await this.compensateCapturedGifts(session, outbox, now) // enqueue 1 CANCEL_GIFT par leg cadeau CAPTURED
await 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.


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 :

  1. 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.

  2. Statut de session — deux voies :

    • Voie explicite : session.markCompleted(now) (uniquement depuis PROCESSING quand tous les legs sont CAPTURED) et session.startRollback(now) (vers ROLLING_BACK, depuis PROCESSING ou COLLECTING).
    • Voie dérivée : session.recomputeStatusFromLegs(now) recalcule le statut à partir de l’ensemble des statuts de legs (deriveSessionStatus). Utilisée après cancelGift, releasePsp, refund — là où l’état terminal dépend de la combinaison des legs (ex. partiellement vs totalement remboursé).
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)

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 :

  1. sessions.listExpiredActive(now, batchSize) — récupère un lot de sessions actives dont expiresAt est dépassé (batchSize par défaut : 50).
  2. Pour chaque session, pour chaque leg :
    • leg gift_card en CAPTUREDleg.startGiftCancel(now) puis enqueue CANCEL_GIFT (reason: 'session_expired', idempotencyKey = cancel:legId).
    • leg psp en HELDleg.startPspRelease(now) puis enqueue RELEASE_HOLD (reason: 'session_expired', idempotencyKey = release:legId).
  3. session.expire(now) ⇒ session en EXPIRED, puis sessions.save(session).
  4. Si eventForStatus(session.status) est terminal et que le marchand a une webhookUrlenqueueMerchantWebhook(...) (événement session.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)"]

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)
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)