Parcours d'appel de bout en bout
À quoi sert cette page
Section intitulée « À quoi sert cette page »Les autres pages du backend décrivent chaque couche isolément (Couche HTTP, Couche Application, Couche Domain, Couche Infrastructure, L orchestrateur, Workers). Cette page-ci fait l’inverse : elle suit un appel réel de bout en bout, saut par saut, en citant le fichier et la fonction à chaque étape. C’est le meilleur point d’entrée pour comprendre comment les pièces s’emboîtent.
Le principe central à garder en tête : rien d’externe (PSP, Titreo) n’est appelé pendant la requête HTTP. Les use cases écrivent en base un état de session + une ligne d’outbox (file de travail), puis répondent. Un worker de fond (OutboxPoller) lit l’outbox et appelle réellement Stripe / Titreo, de façon asynchrone et idempotente. Voir Outbox & orchestration.
Le tronc commun : entrée HTTP
Section intitulée « Le tronc commun : entrée HTTP »Toute requête authentifiée traverse les mêmes premiers sauts avant d’atteindre un handler de route.
sequenceDiagram
participant C as "Client (serveur marchand / navigateur)"
participant H as "Hook onRequest (auth.ts)"
participant P as "preHandler (require-scope / require-session-action)"
participant R as "Handler de route (sessions.routes.ts)"
participant U as "Use case (application/)"
C->>H: "POST /v1/... + Authorization: Bearer <token>"
H->>H: "isPublicPath ? sinon vérifie le token"
H->>P: "req.auth = { merchantId, scopes, apiKeyId, sessionId? }"
P->>R: "scope OK"
R->>U: "container.useCases.xxx.execute(...)"
U-->>R: "résultat domaine"
R-->>C: "serializeSession(...) / DTO"
- Hook
onRequest—api/src/http/plugins/auth.ts, fonctionplugin(leapp.addHook('onRequest', …)). SiisPublicPath(req.url)(ex./health,/v1/webhooks/,/v1/auth/) → laisse passer sans auth. Sinon lit l’en-têteAuthorization: Bearer …:- token de session client (préfixe reconnu par
isSessionToken) →verifySessionToken()(deinfrastructure/crypto/session-token.ts) ; posereq.authavec le scope uniquesessions:clientet unsessionIdlié. - sinon clé API serveur →
opts.authenticate.execute(token)(use caseAuthenticateApiKey) ; posereq.auth = { merchantId, scopes, apiKeyId }. - à défaut, session utilisateur de console via
synthesizeFromUserSession().
- token de session client (préfixe reconnu par
- Hook
preHandler(auth.ts) — si le token est un token client lié à une session, vérifie quereq.params.idcorrespond bien àreq.auth.sessionId(sinon 403). C’est le cloisonnement navigateur. - preHandler de scope —
require-scope.ts:requireScope(scope)(clé API) ourequire-session-action.ts:requireSessionAction(action)(route ouverte aux deux types de jeton). Refuse en 403 si le scope manque. - Handler de route —
api/src/http/routes/sessions.routes.ts. Ne contient aucune logique métier : il parse (Zod), appellecontainer.useCases.<x>.execute(...), puis sérialise viaserializers.ts:serializeSession().
Les sections suivantes reprennent ce tronc puis détaillent la suite, propre à chaque parcours.
Parcours 1 — Split-tender qui réussit
Section intitulée « Parcours 1 — Split-tender qui réussit »Le scénario phare : payer un total avec une carte cadeau + une carte bancaire. Quatre requêtes HTTP (create / add gift / add psp / submit), puis tout le travail PSP/Titreo se fait en arrière-plan via l’outbox.
Vue d’ensemble
Section intitulée « Vue d’ensemble »sequenceDiagram
participant C as Client
participant API as "Routes + Use cases"
participant DB as "Firebird (sessions + outbox)"
participant W as "OutboxPoller (worker)"
participant T as "Titreo"
participant S as "Stripe"
C->>API: "POST /v1/sessions"
API->>DB: "INSERT payment_sessions (status CREATED)"
C->>API: "POST /:id/legs/gift"
API->>DB: "leg gift PENDING + outbox DEBIT_GIFT"
C->>API: "POST /:id/legs/psp"
API->>DB: "leg psp PENDING (status COLLECTING)"
C->>API: "POST /:id/submit"
API->>DB: "status PROCESSING + outbox AUTHORIZE_PSP"
API-->>C: "{ status: PROCESSING }"
Note over W: "Boucle de fond, toutes les ~2s"
W->>DB: "listReady + claim DEBIT_GIFT"
W->>T: "provider.debit(...)"
T-->>W: "CAPTURED"
W->>DB: "leg gift CAPTURED"
W->>DB: "listReady + claim AUTHORIZE_PSP"
W->>S: "paymentIntents.create (manual capture)"
S-->>W: "requires_capture → HELD"
W->>DB: "leg psp HELD + enqueue CAPTURE_PSP"
W->>DB: "listReady + claim CAPTURE_PSP"
W->>S: "paymentIntents.capture(...)"
S-->>W: "succeeded → CAPTURED"
W->>DB: "leg psp CAPTURED → session COMPLETED"
Étape A — POST /v1/sessions (créer la session)
Section intitulée « Étape A — POST /v1/sessions (créer la session) »- Tronc commun (auth) puis
preHandler: requireScope('sessions:write'). sessions.routes.tshandlerPOST /v1/sessions→container.useCases.createSession.execute({ merchantId: req.auth.merchantId, ...req.body }).application/sessions/create-session.ts:CreateSession.execute():- charge et valide le marchand via
MerchantRepository.findById(); rejette si inactif. - ouvre
uow.run(({ sessions }) => …); idempotence métier :sessions.findByReference(merchantId, reference)retourne la session existante si la référence est déjà connue. - construit l’agrégat domaine
domain/session/session.ts:PaymentSession.create(...)(statut initialCREATED), puissessions.save(session).
- charge et valide le marchand via
- Port → DB :
infrastructure/db/firebird-session-repo.ts:FirebirdSessionRepo.save()fait unINSERTdanspayment_sessions. - Handler renvoie
201+serializeSession(session).
Étape B — POST /v1/sessions/:id/legs/gift (ajouter la jambe carte cadeau)
Section intitulée « Étape B — POST /v1/sessions/:id/legs/gift (ajouter la jambe carte cadeau) »preHandler: requireSessionAction('session:add_gift_leg'), puis dans le handlerassertOwnedSession(container, id, merchantId)(404 si la session n’appartient pas au marchand).- Handler →
container.useCases.addGiftLeg.execute({ sessionId, ...req.body }). application/sessions/add-gift-leg.ts:AddGiftLeg.execute(), dansuow.run(({ sessions, outbox }) => …):sessions.findById(sessionId), calcule le plafondcap = min(availableBalance, session.remainingAmount.amount)et ledebitAmount.- crée la jambe via
domain/session/leg.ts:PaymentLeg.createGift(...)(statutPENDING), puissession.addGiftLeg(leg, now)(le domaine passe la session enCOLLECTING). - enfile le travail :
outbox.enqueue({ action: 'DEBIT_GIFT', payload: { token, amount, emitter, cvv? }, idempotencyKey: 'debit:<legId>', … }). sessions.save(session).
- Aucun appel Titreo ici. Le débit réel est différé à l’outbox. Le handler relit la session (
getSession) et la renvoie.
Étape C — POST /v1/sessions/:id/legs/psp (ajouter la jambe PSP)
Section intitulée « Étape C — POST /v1/sessions/:id/legs/psp (ajouter la jambe PSP) »preHandler: requireSessionAction('session:add_psp_leg')+assertOwnedSession.- Handler →
container.useCases.setPspLeg.execute({ sessionId, ...req.body }). application/sessions/set-psp-leg.ts:SetPspLeg.execute(), dansuow.run:- si un
MerchantPspConfigRepositoryest injecté, vérifiepspConfigs.findActive(merchantId, providerType)(sinonMERCHANT_PSP_NOT_CONFIGURED). - calcule
session.remainingAmount; rejette si déjà à zéro. PaymentLeg.createPsp(...)pour le reste exact, puissession.setPspLeg(leg, now)(le domaine exigeleg.amount === remainingAmount).sessions.save(session). Aucun outbox ici : la jambe PSP n’est traitée qu’ausubmit.
- si un
Étape D — POST /v1/sessions/:id/submit (figer et lancer l’exécution)
Section intitulée « Étape D — POST /v1/sessions/:id/submit (figer et lancer l’exécution) »preHandler: requireSessionAction('session:submit')+assertOwnedSession.- Handler →
container.useCases.submitSession.execute({ sessionId }). application/sessions/submit-session.ts:SubmitSession.execute(), dansuow.run(({ sessions, outbox }) => …):session.submit(now)(domain/session/session.ts:submit) : vérifie que la somme des jambes == total et qu’une jambe PSP existe si reste > 0, puis passe enPROCESSING.- s’il n’y a pas de jambe PSP (
session.pspLeg === null) et que toutes les jambes sontCAPTURED, marque directementmarkCompleted(); sinon enfileoutbox.enqueue({ action: 'AUTHORIZE_PSP', idempotencyKey: 'authorize:<pspLegId>', … }). sessions.save(session); renvoie{ status: 'PROCESSING' | 'COMPLETED', outboxId }.
À ce stade, deux lignes d’outbox sont en file : DEBIT_GIFT (depuis l’étape B) et AUTHORIZE_PSP (depuis le submit). La réponse HTTP est déjà partie.
Étape E — le worker draine l’outbox
Section intitulée « Étape E — le worker draine l’outbox »Le worker tourne en continu (démarré dans workers/outbox.ts, qui instancie OutboxPoller(...).start()).
infrastructure/workers/outbox-poller.ts:OutboxPoller.tickOnce()(appelée en boucle parloop()toutes lesintervalMs, défaut 2000 ms) :outbox.listReady(now, batchSize)→ candidats dontnext_attempt_atest dû.- pour chacun,
outbox.claim(id, workerId, now)(verrou ;nullsi déjà pris par un autre worker), puishandle(entry).
OutboxPoller.handle()appellecontainer.useCases.processOutboxEntry.execute(entry)puis, selon le résultat,outbox.markCompleted(...)ououtbox.markFailed(...)(replanifie selonDEFAULT_BACKOFF_SCHEDULE_MS: 1m, 5m, 30m, 2h, 12h, 24h, sinon dead-letter).
DEBIT_GIFT → application/workers/process-outbox-entry.ts:ProcessOutboxEntry.debitGift() :
- relit session+jambe dans
uow.run; si la jambe n’est plusPENDING, renvoieCOMPLETED(idempotence à la rejouabilité). giftRegistry.get(leg.providerType).debit({ token, amount, ticket: idempotencyKey, … })→infrastructure/titreo/titreo-gift-card-adapter.ts:TitreoGiftCardAdapter.debit()(appel HTTPPUT /api/Card;getToken()met en cache le JWT Titreo).- succès →
leg.markGiftDebited(...)(jambeCAPTURED) +sessions.save. Échec →leg.markGiftDebitFailed(...).
AUTHORIZE_PSP → ProcessOutboxEntry.authorizePsp() :
pspRegistry.get(leg.providerType, merchantId).authorize({ amount, currency, paymentMethod, idempotencyKey })→infrastructure/psp/stripe-adapter.ts:StripeAdapter.authorize()(crée un PaymentIntentcapture_method: 'manual',confirm: true, avec la clé d’idempotence Stripe).requires_capture→ mappé enHELD(mapAuthorize). Le worker faitleg.markPspHeld(...)et enfile immédiatementoutbox.enqueue({ action: 'CAPTURE_PSP', idempotencyKey: 'capture:<legId>' }).requires_action(3-D Secure) →leg.markPspRequiresAction(...); le flux s’arrête là côté worker et attend soit le webhook PSP (parcours 2), soitPOST /:id/legs/psp/confirm(confirm-psp-action.ts:ConfirmPspAction, qui enfileCAPTURE_PSP).- échec →
leg.markPspHoldFailed(...)+session.startRollback(now)+compensateCapturedGifts(...)(parcours 3).
CAPTURE_PSP → ProcessOutboxEntry.capturePsp() :
StripeAdapter.capture({ authId, amount, idempotencyKey })→paymentIntents.capture(...).succeeded→leg.markPspCaptured(now); si toutes les jambes sontCAPTUREDet la session estPROCESSING→session.markCompleted(now).sessions.savepuismaybeEnqueueWebhook(session, leg.id)enfile un éventuel webhook marchandsession.completed.
Parcours 2 — Webhook PSP entrant (payment_intent.succeeded)
Section intitulée « Parcours 2 — Webhook PSP entrant (payment_intent.succeeded) »Stripe peut confirmer une capture (ou un 3DS, un échec, un refund) de façon asynchrone via webhook. Le flux est public (pas de Bearer) mais protégé par la signature PSP.
sequenceDiagram
participant S as "Stripe"
participant RT as "webhooks.routes.ts"
participant HW as "HandlePspWebhook"
participant AD as "StripeAdapter.verifyWebhook"
participant DB as "Firebird"
S->>RT: "POST /v1/webhooks/stripe/<merchantId> (raw body + stripe-signature)"
RT->>HW: "execute({ providerType, merchantId, headers, rawBody })"
HW->>AD: "verifyWebhook({ rawBody, signature, secret })"
AD-->>HW: "ok + event (ex: CAPTURE_SUCCEEDED)"
HW->>DB: "markReceived (anti-doublon)"
HW->>DB: "findByPspAuthId(merchantId, providerType, authId)"
HW->>DB: "psp.markPspCaptured + session.markCompleted ?"
HW->>DB: "markProcessed + enqueue webhook marchand"
HW-->>RT: "{ event, legId, sessionStatus }"
RT-->>S: "200"
- Route publique, body brut —
api/src/http/routes/webhooks.routes.ts. Elle remplace les content-type parsers par un parseur buffer (addContentTypeParser('*', { parseAs: 'buffer' }, …)) : la vérification de signature exige le corps exact non reparsé. Rate-limit 60/min,bodyLimit100 Ko. - Le préfixe
/v1/webhooks/est dansPUBLIC_PREFIXESdeauth.ts:isPublicPath()→ pas d’auth Bearer. La confiance vient de la signature. - Handler →
handlePspWebhook.execute({ providerType, merchantId, headers, rawBody }). application/webhooks/handle-psp-webhook.ts:HandlePspWebhook.execute():registry.get(providerType, merchantId)puis lit le secret viapspConfigs.findActive(...).provider.verifyWebhook({ rawBody, signature, secret })→stripe-adapter.ts:StripeAdapter.verifyWebhook()(qui appelleclient.webhooks.constructEvent, puismapWebhookEvent()traduit le type Stripe → événement normalisé :payment_intent.succeeded→CAPTURE_SUCCEEDED,amount_capturable_updated→AUTH_SUCCEEDED,payment_failed→AUTH_FAILED/CAPTURE_FAILED,charge.refunded→REFUND_SUCCEEDED,charge.dispute.created→DISPUTED, etc.). Signature invalide →INVALID_SIGNATURE/INVALID_PAYLOAD→ la route renvoie 400.- dans
uow.run(({ sessions, outbox, webhookEvents }) => …):webhookEvents.markReceived(...); siduplicate→ renvoieDUPLICATE(idempotence d’ingestion). Stripe peut renvoyer le même événement plusieurs fois : on n’agit qu’une fois. - retrouve la session par l’identifiant PSP :
sessions.findByPspAuthId(merchantId, providerType, event.authId)→firebird-session-repo.ts:findByPspAuthId()(jointurepayment_legs↔payment_sessions, filtréemerchant_idpour éviter une collision cross-tenant). switch (event.type)mute le domaine : pourCAPTURE_SUCCEEDED, sipsp.status === 'HELD'→psp.markPspCaptured(now), puis si toutes les jambes sontCAPTUREDet sessionPROCESSING→session.markCompleted(now). (AUTH_SUCCEEDED→markPspHeld;AUTH_FAILED/CAPTURE_FAILED→ échec +startRollback+ compensation gift ;REFUND_SUCCEEDED→startRefund+markRefunded.)sessions.save(session),webhookEvents.markProcessed(...), et si le marchand a unewebhookUrl, enfile un webhook marchand viaenqueueMerchantWebhook(...)(traité plus tard parProcessOutboxEntry.merchantWebhook()→WebhookSender.send).
- Route renvoie 200 +
{ event, legId, sessionStatus }.
Parcours 3 — Échec PSP → rollback / compensation de la carte cadeau
Section intitulée « Parcours 3 — Échec PSP → rollback / compensation de la carte cadeau »Cas critique d’un split-tender : la carte cadeau a déjà été débitée (jambe gift CAPTURED) mais la carte bancaire est refusée. On ne peut pas laisser le client débité de l’avoir cadeau sans contrepartie : il faut annuler le débit gift (compensation). C’est une saga avec compensation, pas une transaction distribuée.
sequenceDiagram
participant W as "OutboxPoller"
participant POE as "ProcessOutboxEntry"
participant S as "Stripe"
participant DB as "Firebird"
participant T as "Titreo"
Note over DB: "gift leg = CAPTURED, psp leg = PENDING"
W->>POE: "AUTHORIZE_PSP"
POE->>S: "authorize(...)"
S-->>POE: "FAILED (card_declined)"
POE->>DB: "leg psp HOLD_FAILED + session.startRollback"
POE->>DB: "compensateCapturedGifts → gift leg CANCELING + enqueue CANCEL_GIFT"
POE-->>W: "FAILED (replanifié/dead-letter)"
W->>POE: "CANCEL_GIFT"
POE->>T: "provider.cancel(token, amount, originalTicket, ticket='debit:<legId>')"
T-->>POE: "CANCELED"
POE->>DB: "leg gift CANCELED + session.recomputeStatusFromLegs"
- Le worker traite
AUTHORIZE_PSP(ouCAPTURE_PSP) comme au parcours 1, mais l’adapter renvoieFAILED. Dansprocess-outbox-entry.ts:authorizePsp()(idemcapturePsp()) :leg.markPspHoldFailed(...)(oumarkPspCaptureFailed(...)) passe la jambe PSP en échec.session.startRollback(now)(domain/session/session.ts:startRollback) fait basculer la session en rollback.this.compensateCapturedGifts(session, outbox, now): pour chaque jambegift_cardencoreCAPTURED, appelleleg.startGiftCancel(now)(jambe →CANCELING) et enfileoutbox.enqueue({ action: 'CANCEL_GIFT', idempotencyKey: 'cancel:<legId>', payload: { transactionId, reason: 'psp_failure_rollback' } }).sessions.save(session); le use case renvoieFAILED(la ligneAUTHORIZE_PSPsera replanifiée puis dead-letterée, mais le rollback gift, lui, est déjà enfilé).
- Au tick suivant, le worker traite
CANCEL_GIFT→process-outbox-entry.ts:cancelGift():- si la jambe n’est plus
CANCELING→COMPLETED(rejouabilité). - point clé d’idempotence Titreo : l’annulation réutilise le ticket du débit d’origine :
ticket = 'debit:<legId>'(commentaire explicite dans le code). Cela permet à Titreo de rapprocher l’annulation de la transaction d’origine. giftRegistry.get(leg.providerType).cancel({ token, amount, originalTicket, ticket: 'debit:<legId>', cvv? })→titreo-gift-card-adapter.ts:TitreoGiftCardAdapter.cancel()(HTTPDELETE /api/Card).CANCELED→leg.markGiftCanceled(now)+session.recomputeStatusFromLegs(now)(recalcule le statut session à partir de l’état réel des jambes) +sessions.save.
- si la jambe n’est plus
Le même mécanisme de compensation est déclenché par le webhook quand l’échec arrive par là (handle-psp-webhook.ts, branches AUTH_FAILED / CAPTURE_FAILED / CANCELED : session.startRollback + needsCompensation = true → même boucle d’enqueue CANCEL_GIFT). Les deux chemins convergent sur la même file et les mêmes clés d’idempotence.
Parcours bonus — Remboursement (POST /v1/sessions/:id/refund)
Section intitulée « Parcours bonus — Remboursement (POST /v1/sessions/:id/refund) »Pour comprendre le pendant « après COMPLETED » :
preHandler: requireScope('sessions:write')+assertOwnedSession.- Handler →
container.useCases.refundSession.execute({ sessionId, reason? }), puisreq.audit({ action: 'session.refund', … })(journal d’audit). application/sessions/refund-session.ts:RefundSession.execute()dansuow.run:- n’accepte que
COMPLETED/PARTIALLY_REFUNDED; sélectionne les jambesCAPTUREDselon lastrategy(gift_first|cb_first|all, défautall). - pour chaque jambe cible :
leg.startRefund(now)puisoutbox.enqueue({ action: 'REFUND', idempotencyKey: '<base>:<legId>', payload … }). session.recomputeStatusFromLegs(now)+sessions.save.
- n’accepte que
- Worker →
process-outbox-entry.ts:refund(): jambe PSP →StripeAdapter.refund()(refunds.create) ; jambe gift →TitreoGiftCardAdapter.refund()(DELETE /api/Card, ticketdebit:<legId>). Succès →leg.markRefunded+recomputeStatusFromLegs.
Tableau récapitulatif des fichiers traversés
Section intitulée « Tableau récapitulatif des fichiers traversés »Fichier (app/) |
Rôle dans les parcours | Symboles clés cités |
|---|---|---|
api/src/http/plugins/auth.ts |
Authentifie chaque requête (clé API, token client, session console) | hook onRequest, isPublicPath, synthesizeFromUserSession |
api/src/http/plugins/require-scope.ts |
Garde de scope serveur | requireScope(scope) |
api/src/http/plugins/require-session-action.ts |
Garde mixte clé API / token client | requireSessionAction(action) |
api/src/http/routes/sessions.routes.ts |
Routes de session/legs/submit/refund (parse + délègue) | handlers POST /v1/sessions, /:id/legs/gift, /:id/legs/psp, /:id/submit, /:id/refund, assertOwnedSession |
api/src/http/routes/webhooks.routes.ts |
Webhook PSP entrant (body brut, public) | webhooksRoutes, addContentTypeParser |
api/src/application/sessions/create-session.ts |
Crée la session (idempotente par reference) |
CreateSession.execute |
api/src/application/sessions/add-gift-leg.ts |
Ajoute jambe gift + enfile DEBIT_GIFT |
AddGiftLeg.execute |
api/src/application/sessions/set-psp-leg.ts |
Ajoute jambe PSP pour le reste | SetPspLeg.execute |
api/src/application/sessions/submit-session.ts |
Fige la session, enfile AUTHORIZE_PSP |
SubmitSession.execute |
api/src/application/sessions/confirm-psp-action.ts |
Après 3DS, enfile CAPTURE_PSP |
ConfirmPspAction.execute |
api/src/application/sessions/refund-session.ts |
Sélectionne les jambes et enfile REFUND |
RefundSession.execute |
api/src/application/workers/process-outbox-entry.ts |
Exécute chaque action outbox (cœur orchestration) | debitGift, authorizePsp, capturePsp, cancelGift, refund, compensateCapturedGifts |
api/src/application/webhooks/handle-psp-webhook.ts |
Ingestion webhook PSP → mutation domaine | HandlePspWebhook.execute, switch(event.type) |
api/src/infrastructure/workers/outbox-poller.ts |
Boucle de fond qui draine l’outbox | OutboxPoller.tickOnce, handle, DEFAULT_BACKOFF_SCHEDULE_MS |
api/src/infrastructure/db/firebird-session-repo.ts |
Persistance sessions + legs (SQL direct) | save, findById, findByPspAuthId |
api/src/infrastructure/psp/stripe-adapter.ts |
Appels Stripe + mapping webhook | authorize, capture, refund, verifyWebhook, mapWebhookEvent |
api/src/infrastructure/titreo/titreo-gift-card-adapter.ts |
Appels Titreo (carte cadeau) | debit, cancel, refund, getToken |
Chemin d’appel commenté (split-tender, du HTTP au PSP)
Section intitulée « Chemin d’appel commenté (split-tender, du HTTP au PSP) »POST /v1/sessions/:id/submit └─ http/plugins/auth.ts (onRequest → req.auth) └─ http/plugins/require-session-action.ts requireSessionAction('session:submit') └─ http/routes/sessions.routes.ts handler submit → assertOwnedSession() └─ application/sessions/submit-session.ts SubmitSession.execute() └─ uow.run(({ sessions, outbox }) => …) [transaction Firebird] ├─ domain/session/session.ts session.submit(now) → status PROCESSING ├─ outbox.enqueue({ action:'AUTHORIZE_PSP', idempotencyKey:'authorize:<legId>' }) └─ infrastructure/db/firebird-session-repo.ts save() → UPDATE/INSERT ◀── réponse HTTP { status:'PROCESSING' } (aucun appel PSP encore)
~plus tard, en arrière-plan~infrastructure/workers/outbox-poller.ts OutboxPoller.tickOnce() └─ outbox.listReady() + outbox.claim() └─ application/workers/process-outbox-entry.ts authorizePsp(entry) ├─ pspRegistry.get('stripe', merchantId) ├─ infrastructure/psp/stripe-adapter.ts authorize() → Stripe paymentIntents.create (HELD) ├─ domain leg.markPspHeld(...) └─ outbox.enqueue({ action:'CAPTURE_PSP', idempotencyKey:'capture:<legId>' }) (tick suivant) → capturePsp(entry) ├─ stripe-adapter.ts capture() → Stripe paymentIntents.capture (CAPTURED) ├─ domain leg.markPspCaptured(...) + session.markCompleted() si toutes legs CAPTURED └─ maybeEnqueueWebhook(session) → webhook marchand 'session.completed'Pour aller plus loin
Section intitulée « Pour aller plus loin »- L orchestrateur — détail des actions outbox et de la saga.
- Workers — le poller, le backoff, le dead-letter, le nettoyage des sessions expirées.
- Idempotence — pourquoi
debit:/authorize:/capture:/cancel:comme clés. - Machines à états — toutes les transitions de
PaymentSessionetPaymentLeg. - Sécurité — auth, signatures webhook, chiffrement du CVV (AEAD).