Aller au contenu

Parcours d'appel de bout en bout

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.

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"
  1. Hook onRequestapi/src/http/plugins/auth.ts, fonction plugin (le app.addHook('onRequest', …)). Si isPublicPath(req.url) (ex. /health, /v1/webhooks/, /v1/auth/) → laisse passer sans auth. Sinon lit l’en-tête Authorization: Bearer … :
    • token de session client (préfixe reconnu par isSessionToken) → verifySessionToken() (de infrastructure/crypto/session-token.ts) ; pose req.auth avec le scope unique sessions:client et un sessionId lié.
    • sinon clé API serveur → opts.authenticate.execute(token) (use case AuthenticateApiKey) ; pose req.auth = { merchantId, scopes, apiKeyId }.
    • à défaut, session utilisateur de console via synthesizeFromUserSession().
  2. Hook preHandler (auth.ts) — si le token est un token client lié à une session, vérifie que req.params.id correspond bien à req.auth.sessionId (sinon 403). C’est le cloisonnement navigateur.
  3. preHandler de scoperequire-scope.ts:requireScope(scope) (clé API) ou require-session-action.ts:requireSessionAction(action) (route ouverte aux deux types de jeton). Refuse en 403 si le scope manque.
  4. Handler de routeapi/src/http/routes/sessions.routes.ts. Ne contient aucune logique métier : il parse (Zod), appelle container.useCases.<x>.execute(...), puis sérialise via serializers.ts:serializeSession().

Les sections suivantes reprennent ce tronc puis détaillent la suite, propre à chaque parcours.


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.

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) »
  1. Tronc commun (auth) puis preHandler: requireScope('sessions:write').
  2. sessions.routes.ts handler POST /v1/sessionscontainer.useCases.createSession.execute({ merchantId: req.auth.merchantId, ...req.body }).
  3. 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 initial CREATED), puis sessions.save(session).
  4. Port → DB : infrastructure/db/firebird-session-repo.ts:FirebirdSessionRepo.save() fait un INSERT dans payment_sessions.
  5. 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) »
  1. preHandler: requireSessionAction('session:add_gift_leg'), puis dans le handler assertOwnedSession(container, id, merchantId) (404 si la session n’appartient pas au marchand).
  2. Handler → container.useCases.addGiftLeg.execute({ sessionId, ...req.body }).
  3. application/sessions/add-gift-leg.ts:AddGiftLeg.execute(), dans uow.run(({ sessions, outbox }) => …) :
    • sessions.findById(sessionId), calcule le plafond cap = min(availableBalance, session.remainingAmount.amount) et le debitAmount.
    • crée la jambe via domain/session/leg.ts:PaymentLeg.createGift(...) (statut PENDING), puis session.addGiftLeg(leg, now) (le domaine passe la session en COLLECTING).
    • enfile le travail : outbox.enqueue({ action: 'DEBIT_GIFT', payload: { token, amount, emitter, cvv? }, idempotencyKey: 'debit:<legId>', … }).
    • sessions.save(session).
  4. 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) »
  1. preHandler: requireSessionAction('session:add_psp_leg') + assertOwnedSession.
  2. Handler → container.useCases.setPspLeg.execute({ sessionId, ...req.body }).
  3. application/sessions/set-psp-leg.ts:SetPspLeg.execute(), dans uow.run :
    • si un MerchantPspConfigRepository est injecté, vérifie pspConfigs.findActive(merchantId, providerType) (sinon MERCHANT_PSP_NOT_CONFIGURED).
    • calcule session.remainingAmount ; rejette si déjà à zéro.
    • PaymentLeg.createPsp(...) pour le reste exact, puis session.setPspLeg(leg, now) (le domaine exige leg.amount === remainingAmount).
    • sessions.save(session). Aucun outbox ici : la jambe PSP n’est traitée qu’au submit.

É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) »
  1. preHandler: requireSessionAction('session:submit') + assertOwnedSession.
  2. Handler → container.useCases.submitSession.execute({ sessionId }).
  3. application/sessions/submit-session.ts:SubmitSession.execute(), dans uow.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 en PROCESSING.
    • s’il n’y a pas de jambe PSP (session.pspLeg === null) et que toutes les jambes sont CAPTURED, marque directement markCompleted() ; sinon enfile outbox.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.

Le worker tourne en continu (démarré dans workers/outbox.ts, qui instancie OutboxPoller(...).start()).

  1. infrastructure/workers/outbox-poller.ts:OutboxPoller.tickOnce() (appelée en boucle par loop() toutes les intervalMs, défaut 2000 ms) :
    • outbox.listReady(now, batchSize) → candidats dont next_attempt_at est dû.
    • pour chacun, outbox.claim(id, workerId, now) (verrou ; null si déjà pris par un autre worker), puis handle(entry).
  2. OutboxPoller.handle() appelle container.useCases.processOutboxEntry.execute(entry) puis, selon le résultat, outbox.markCompleted(...) ou outbox.markFailed(...) (replanifie selon DEFAULT_BACKOFF_SCHEDULE_MS : 1m, 5m, 30m, 2h, 12h, 24h, sinon dead-letter).

DEBIT_GIFTapplication/workers/process-outbox-entry.ts:ProcessOutboxEntry.debitGift() :

  • relit session+jambe dans uow.run ; si la jambe n’est plus PENDING, renvoie COMPLETED (idempotence à la rejouabilité).
  • giftRegistry.get(leg.providerType).debit({ token, amount, ticket: idempotencyKey, … })infrastructure/titreo/titreo-gift-card-adapter.ts:TitreoGiftCardAdapter.debit() (appel HTTP PUT /api/Card ; getToken() met en cache le JWT Titreo).
  • succès → leg.markGiftDebited(...) (jambe CAPTURED) + sessions.save. Échec → leg.markGiftDebitFailed(...).

AUTHORIZE_PSPProcessOutboxEntry.authorizePsp() :

  • pspRegistry.get(leg.providerType, merchantId).authorize({ amount, currency, paymentMethod, idempotencyKey })infrastructure/psp/stripe-adapter.ts:StripeAdapter.authorize() (crée un PaymentIntent capture_method: 'manual', confirm: true, avec la clé d’idempotence Stripe).
  • requires_capture → mappé en HELD (mapAuthorize). Le worker fait leg.markPspHeld(...) et enfile immédiatement outbox.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), soit POST /:id/legs/psp/confirm (confirm-psp-action.ts:ConfirmPspAction, qui enfile CAPTURE_PSP).
  • échec → leg.markPspHoldFailed(...) + session.startRollback(now) + compensateCapturedGifts(...) (parcours 3).

CAPTURE_PSPProcessOutboxEntry.capturePsp() :

  • StripeAdapter.capture({ authId, amount, idempotencyKey })paymentIntents.capture(...).
  • succeededleg.markPspCaptured(now) ; si toutes les jambes sont CAPTURED et la session est PROCESSINGsession.markCompleted(now).
  • sessions.save puis maybeEnqueueWebhook(session, leg.id) enfile un éventuel webhook marchand session.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"
  1. Route publique, body brutapi/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, bodyLimit 100 Ko.
  2. Le préfixe /v1/webhooks/ est dans PUBLIC_PREFIXES de auth.ts:isPublicPath() → pas d’auth Bearer. La confiance vient de la signature.
  3. Handler → handlePspWebhook.execute({ providerType, merchantId, headers, rawBody }).
  4. application/webhooks/handle-psp-webhook.ts:HandlePspWebhook.execute() :
    • registry.get(providerType, merchantId) puis lit le secret via pspConfigs.findActive(...).
    • provider.verifyWebhook({ rawBody, signature, secret })stripe-adapter.ts:StripeAdapter.verifyWebhook() (qui appelle client.webhooks.constructEvent, puis mapWebhookEvent() traduit le type Stripe → événement normalisé : payment_intent.succeededCAPTURE_SUCCEEDED, amount_capturable_updatedAUTH_SUCCEEDED, payment_failedAUTH_FAILED/CAPTURE_FAILED, charge.refundedREFUND_SUCCEEDED, charge.dispute.createdDISPUTED, etc.). Signature invalide → INVALID_SIGNATURE/INVALID_PAYLOAD → la route renvoie 400.
    • dans uow.run(({ sessions, outbox, webhookEvents }) => …) : webhookEvents.markReceived(...) ; si duplicate → renvoie DUPLICATE (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() (jointure payment_legspayment_sessions, filtrée merchant_id pour éviter une collision cross-tenant).
    • switch (event.type) mute le domaine : pour CAPTURE_SUCCEEDED, si psp.status === 'HELD'psp.markPspCaptured(now), puis si toutes les jambes sont CAPTURED et session PROCESSINGsession.markCompleted(now). (AUTH_SUCCEEDEDmarkPspHeld; AUTH_FAILED/CAPTURE_FAILED → échec + startRollback + compensation gift ; REFUND_SUCCEEDEDstartRefund+markRefunded.)
    • sessions.save(session), webhookEvents.markProcessed(...), et si le marchand a une webhookUrl, enfile un webhook marchand via enqueueMerchantWebhook(...) (traité plus tard par ProcessOutboxEntry.merchantWebhook()WebhookSender.send).
  5. 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"
  1. Le worker traite AUTHORIZE_PSP (ou CAPTURE_PSP) comme au parcours 1, mais l’adapter renvoie FAILED. Dans process-outbox-entry.ts:authorizePsp() (idem capturePsp()) :
    • leg.markPspHoldFailed(...) (ou markPspCaptureFailed(...)) 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 jambe gift_card encore CAPTURED, appelle leg.startGiftCancel(now) (jambe → CANCELING) et enfile outbox.enqueue({ action: 'CANCEL_GIFT', idempotencyKey: 'cancel:<legId>', payload: { transactionId, reason: 'psp_failure_rollback' } }).
    • sessions.save(session) ; le use case renvoie FAILED (la ligne AUTHORIZE_PSP sera replanifiée puis dead-letterée, mais le rollback gift, lui, est déjà enfilé).
  2. Au tick suivant, le worker traite CANCEL_GIFTprocess-outbox-entry.ts:cancelGift() :
    • si la jambe n’est plus CANCELINGCOMPLETED (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() (HTTP DELETE /api/Card).
    • CANCELEDleg.markGiftCanceled(now) + session.recomputeStatusFromLegs(now) (recalcule le statut session à partir de l’état réel des jambes) + sessions.save.

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 » :

  1. preHandler: requireScope('sessions:write') + assertOwnedSession.
  2. Handler → container.useCases.refundSession.execute({ sessionId, reason? }), puis req.audit({ action: 'session.refund', … }) (journal d’audit).
  3. application/sessions/refund-session.ts:RefundSession.execute() dans uow.run :
    • n’accepte que COMPLETED / PARTIALLY_REFUNDED ; sélectionne les jambes CAPTURED selon la strategy (gift_first | cb_first | all, défaut all).
    • pour chaque jambe cible : leg.startRefund(now) puis outbox.enqueue({ action: 'REFUND', idempotencyKey: '<base>:<legId>', payload … }).
    • session.recomputeStatusFromLegs(now) + sessions.save.
  4. Worker → process-outbox-entry.ts:refund() : jambe PSP → StripeAdapter.refund() (refunds.create) ; jambe gift → TitreoGiftCardAdapter.refund() (DELETE /api/Card, ticket debit:<legId>). Succès → leg.markRefunded + recomputeStatusFromLegs.

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'
  • 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 PaymentSession et PaymentLeg.
  • Sécurité — auth, signatures webhook, chiffrement du CVV (AEAD).