Couche HTTP (Fastify)
À quoi sert cette couche
Section intitulée « À quoi sert cette couche »La couche HTTP est la porte d’entrée réseau de l’API. Tout le reste du backend (use cases, domaine, infrastructure) est volontairement ignorant du protocole HTTP : il manipule des objets et des ports. Le rôle de api/src/http/ est de faire le pont entre le monde extérieur (requêtes JSON, en-têtes, cookies, tokens) et ces use cases.
Concrètement, cette couche fait trois choses, et rien d’autre :
- Parser et valider l’entrée (corps, query, params) avec Zod.
- Appeler le use case correspondant via le conteneur d’injection.
- Sérialiser la sortie (entité domaine → DTO JSON) et choisir le code HTTP.
Tout ce qui est transversal — authentification, autorisation par scope, idempotence, audit, gestion d’erreur, identifiant de corrélation — est délégué à des plugins Fastify branchés sur des hooks, pour ne jamais polluer les handlers.
flowchart LR C["Client (SDK / Console / PSP)"] --> P["Plugins (hooks: auth, idempotency, audit...)"] P --> H["Handler de route (parse Zod)"] H --> U["Use case (application/)"] U --> D["Domaine + Infrastructure"] H --> S["Sérialiseur (entité -> DTO)"] S --> C
1. Démarrage : server.ts → buildHttpApp
Section intitulée « 1. Démarrage : server.ts → buildHttpApp »Le point d’entrée du processus est api/src/http/server.ts. Sa fonction main() fait, dans l’ordre :
- Charge la config (
loadConfig()depuisconfig/) — c’est le seul endroit qui litprocess.env. - Vérifie la présence de
PSP_CONFIG_KEK(clé de chiffrement des secrets PSP), sinon échec immédiat. - Construit le logger Pino avec une liste de
redactPaths(PAN, CVV, tokens, secrets, en-têtesauthorization/cookie/stripe-signature/x-titreo-signaturecensurés[REDACTED]). - Ouvre la connexion Firebird (
FirebirdDb.connect), instancie les adapters d’infrastructure (repos, AEAD, signer de manifeste…). Les registres PSP et cartes cadeaux viennent debuildRegistries(infrastructure/composition/registries.ts), la même fonction que pour les deux workers. - Appelle
buildContainer(...)(voir Couche Application) pour obtenir le conteneur d’injection. - Appelle
buildHttpApp(...)(dansapp.ts) qui assemble l’instance Fastify. - Branche
SIGINT/SIGTERMsur unshutdown(ferme l’app puis la DB) et appelleapp.listen({ port, host }).
buildHttpApp(opts) dans api/src/http/app.ts est la fabrique testable de l’application : elle reçoit le container déjà construit plus des options (clientTokenSecret, cookieSecret, nodeEnv, corsAllowedOrigins…) et retourne une FastifyInstance. La séparation server.ts (câblage des dépendances réelles) / app.ts (assemblage HTTP) permet aux tests d’intégration de construire l’app avec des ports mockés sans toucher au réseau.
sequenceDiagram
participant Node as "Process Node"
participant Srv as "server.ts main()"
participant Cfg as "config/loadConfig"
participant DB as "FirebirdDb"
participant Cnt as "buildContainer"
participant App as "app.ts buildHttpApp"
Node->>Srv: démarrage
Srv->>Cfg: loadConfig()
Srv->>DB: connect(config.firebird)
Srv->>Cnt: buildContainer(ports réels)
Srv->>App: buildHttpApp({ container, options })
App-->>Srv: FastifyInstance
Srv->>App: app.listen({ port, host })
Dans buildHttpApp, l’ordre d’enregistrement est significatif (les hooks s’exécutent dans l’ordre d’inscription) :
validatorCompiler/serializerCompilerdefastify-type-provider-zod→ Zod devient le moteur de validation et de sérialisation des schémas de route.@fastify/helmet(en-têtes de sécurité, CSP désactivée ici).@fastify/rate-limit(global désactivé :global: false; chaque route opte-in viaconfig.rateLimit).- Plugins maison :
error-handler,request-context,call-log,metrics, puis@fastify/cors,@fastify/cookie,user-session,auth,idempotency,audit. - En non-production uniquement :
@fastify/swagger+ Swagger UI sur/docs. - Tous les modules de routes (
healthRoutes,meRoutes(),sessionsRoutes(...), etc.).
2. Les plugins (transversaux)
Section intitulée « 2. Les plugins (transversaux) »Tous sont dans api/src/http/plugins/. Sauf require-scope et require-session-action (de simples fabriques de preHandler, pas des plugins fastify-plugin), ils sont enveloppés par fp(...) et déclarent leurs dépendances.
| Fichier | Hook(s) | Rôle |
|---|---|---|
plugins/request-context.ts |
onRequest |
Génère/propage un req.correlationId (depuis l’en-tête x-correlation-id s’il tient en 64 caractères, sinon un randomUUID), ouvre le contexte ambiant, renvoie l’identifiant dans la réponse. |
plugins/call-log.ts |
preHandler + onResponse |
Pose sessionId dans le contexte ambiant sur les routes /v1/sessions/:id…, puis écrit une ligne http_in par requête : gabarit de route, statut, durée, code d’erreur métier. Remplace les deux lignes par requête de Fastify (disableRequestLogging). Voir Traçabilité. |
plugins/error-handler.ts |
setErrorHandler |
Convertit ZodError, DomainError, erreurs de validation Fastify et erreurs inconnues en réponses { code, message, details? } + code HTTP. Voir détail ci-dessous. |
plugins/user-session.ts |
onRequest |
Lit le cookie tep_session, résout la session utilisateur (ResolveUserSession) et la pose sur req.userSession (admin ou marchand). Dépend de @fastify/cookie. |
plugins/auth.ts |
onRequest + preHandler |
Cœur de l’authentification : valide le Bearer (API key OU client token), ou synthétise une auth depuis req.userSession, et pose req.auth = { merchantId, scopes, apiKeyId, sessionId? }. Dépend de user-session. |
plugins/idempotency.ts |
preHandler + onSend |
Sur méthode mutante + en-tête Idempotency-Key : rejoue une réponse 2xx déjà enregistrée, détecte les conflits de payload, persiste la réponse après coup. Voir Idempotence. |
plugins/audit.ts |
onRequest |
Expose req.audit(event) ; chaque route appelle cette fonction pour journaliser une action sensible (création de clé, refund, etc.) via le use case WriteAuditLog. |
plugins/require-scope.ts |
preHandler (fabrique) |
requireScope(scope) → 403 si req.auth.scopes ne contient pas le scope requis (le scope admin passe tout). |
plugins/require-session-action.ts |
preHandler (fabrique) |
requireSessionAction(action) → autorise soit via le scope serveur (sessions:read/write), soit via un client token sur l’allowlist d’actions. |
Chaîne d’authentification (auth.ts)
Section intitulée « Chaîne d’authentification (auth.ts) »Le hook onRequest du plugin auth laisse passer les chemins publics (/health, /docs*, /v1/webhooks/*, /v1/auth/*) puis distingue trois sources d’identité :
1. En-tête "Authorization: Bearer <token>" ├─ token = client token (issu de /client-token) -> scopes = ['sessions:client'], sessionId lié └─ sinon token = API key -> AuthenticateApiKey.execute(token) -> merchantId + scopes2. Pas de Bearer mais cookie de session valide (req.userSession) -> synthèse d'une auth : admin (scope 'admin') OU marchand (scopes dérivés du rôle owner/developer/ops)3. Ni l'un ni l'autre -> 401 UNAUTHORIZEDLe second hook preHandler du plugin auth verrouille les client tokens : si req.auth.sessionId est présent, la requête doit cibler une route /v1/sessions/:id/* dont le :id correspond exactement au sessionId du token, sinon 403. C’est ce qui empêche un token navigateur d’agir sur une autre session. Voir Sécurité.
Gestion d’erreur (error-handler.ts)
Section intitulée « Gestion d’erreur (error-handler.ts) »Une seule fonction setErrorHandler normalise toutes les sorties d’erreur :
| Type d’erreur | Code HTTP | Corps |
|---|---|---|
ZodError |
400 | { code: 'VALIDATION_ERROR', message, details: flatten() } |
DomainError (domain/shared/errors.ts) |
via table STATUS_BY_CODE |
{ code, message, details? } (details filtrés en prod) |
Erreur de validation Fastify (.validation) |
400 | { code: 'VALIDATION_ERROR', ... } |
| Autre / inconnue | statusCode ?? 500 |
message masqué en Internal server error si ≥ 500 |
La table STATUS_BY_CODE mappe : VALIDATION_ERROR/CURRENCY_MISMATCH → 400, INVALID_STATE_TRANSITION/INVARIANT_VIOLATION → 409, INSUFFICIENT_BALANCE → 422, PROVIDER_ERROR → 502, GIFT_PROVIDER_NOT_CONFIGURED → 409, GIFT_CREDENTIALS_REJECTED → 422, GIFT_PROVIDER_UNREACHABLE → 503, LOGS_DISABLED → 503, LOGS_UNAVAILABLE → 502. En production, sanitizeDetails n’autorise qu’une whitelist de champs par code (ex. currentState/expectedState), pour éviter de fuiter des IDs internes inter-tenant.
3. Toutes les routes
Section intitulée « 3. Toutes les routes »Chaque module de route est une fabrique xxxRoutes(deps) qui retourne un plugin Fastify et reçoit ses use cases depuis le conteneur. Les routes typées utilisent app.withTypeProvider<ZodTypeProvider>() pour brancher les schémas Zod. Préfixe commun : /v1 (sauf /health).
Colonne « auth » : public = pas de Bearer requis ; scope:X = requireScope('X') ; session-action:X = requireSessionAction('X') (API key OU client token autorisé) ; cookie = résolu via session utilisateur ; — = authentifié mais sans garde de scope supplémentaire (/v1/me, /v1/merchants/me, /v1/audit-logs).
Santé & identité
Section intitulée « Santé & identité »| Fichier | Méthode + Path | Use case | Auth |
|---|---|---|---|
routes/health.routes.ts |
GET /health |
(inline) { status, uptime } |
public |
routes/me.routes.ts |
GET /v1/me |
(inline) renvoie req.auth |
— |
Sessions & legs — routes/sessions.routes.ts
Section intitulée « Sessions & legs — routes/sessions.routes.ts »| Méthode + Path | Use case | Auth |
|---|---|---|
POST /v1/sessions |
createSession |
scope:sessions:write |
GET /v1/sessions |
listSessions — filtres status, reference (exact), q (« contient », référence ou métadonnées), from, to, curseur |
scope:sessions:read |
GET /v1/sessions/:id |
getSession |
session-action:session:read |
POST /v1/sessions/:id/legs/gift |
addGiftLeg (+ getSession) |
session-action:session:add_gift_leg |
POST /v1/sessions/:id/legs/psp |
setPspLeg (+ getSession) |
session-action:session:add_psp_leg |
DELETE /v1/sessions/:id/legs/:legId |
removeLeg (+ getSession) |
session-action:session:remove_leg |
POST /v1/sessions/:id/gift-cards/check-balance |
checkGiftBalance |
session-action:session:check_balance |
POST /v1/sessions/:id/legs/psp/confirm |
confirmPspAction |
scope:sessions:write |
POST /v1/sessions/:id/submit |
submitSession |
session-action:session:submit |
POST /v1/sessions/:id/client-token |
(inline) signSessionToken |
scope:sessions:write |
POST /v1/sessions/:id/refund |
refundSession (+ req.audit) — voir Remboursement |
scope:sessions:write |
GET /v1/sessions/:id/events |
listEvents — chronologie du journal métier, cloisonnée par marchand (routes/events.routes.ts) |
scope:sessions:read |
PATCH /v1/sessions/:id/metadata |
updateSessionMetadata (+ req.audit) — fusion superficielle, convention metadata.order = { id, number, url? } posée par le plugin après création de la commande |
scope:sessions:write |
GET /v1/sessions/export.csv |
exportSessionsCsv en flux (routes/sessions-export.ts), format=excel-fr|standard, fenêtre ≤ 1 an (367 jours) |
scope:sessions:read |
GET /v1/admin/sessions/export.csv |
idem, tous marchands ou merchantId |
scope:admin |
Remboursement par moyen de paiement
Section intitulée « Remboursement par moyen de paiement »POST /v1/sessions/:id/refund rembourse des moyens de paiement, chacun en entier et sur son support d’origine. Il n’existe pas de remboursement d’une fraction d’un moyen de paiement.
Corps (RefundBodySchema, schéma strict) :
| Champ | Forme | Effet |
|---|---|---|
reason |
string, 1 à 255 caractères, facultatif | Motif, reporté dans le journal |
legIds |
tableau de 1 à 20 identifiants, facultatif | Moyens de paiement à rembourser. Absent : tous les moyens encaissés |
strategy |
facultatif, déprécié | Seule la valeur all reste acceptée |
Refus en 400 :
strategy: 'gift_first'ou'cb_first'— messageREFUND_STRATEGY_DEPRECATED. Ces valeurs ne faisaient que réordonner les legs : tout était remboursé quel que soit le choix ;legIdsetstrategyensemble ;- toute clé inconnue dans le corps (un montant, un ancien champ).
La sélection est validée en bloc avant tout effet. Un identifiant étranger à la session donne 400 ; un leg qui n’est pas CAPTURED donne 409. Dans les deux cas rien n’est remboursé. Les legs non désignés restent encaissés et remboursables plus tard.
Réponse (RefundResponseSchema) :
{ "sessionId": "…", "status": "PARTIALLY_REFUNDED", "refundedLegIds": ["…"], "refundedAmount": 2500, "currency": "EUR", "outboxIds": ["…"]}refundedAmount est la somme des legs remboursés, en centimes. Le remboursement lui-même est asynchrone : une entrée d’outbox REFUND par leg. Après chaque leg remboursé, le worker prévient le marchand par webhook. Voir L’orchestrateur.
Cartes cadeaux standalone — routes/gift-cards.routes.ts
Section intitulée « Cartes cadeaux standalone — routes/gift-cards.routes.ts »Endpoints « hors session » qui pilotent le GiftCardProvider du marchand authentifié (pour les surfaces Shopify où Shopify fait le split de tender). Un marchand sans accès enregistrés reçoit 409 GIFT_PROVIDER_NOT_CONFIGURED.
| Méthode + Path | Use case | Auth |
|---|---|---|
POST /v1/gift-cards/check-balance |
lookupGiftBalance |
scope:sessions:read |
POST /v1/gift-cards/debit |
debitGiftCard |
scope:sessions:write |
POST /v1/gift-cards/refund |
refundGiftCard |
scope:sessions:write |
debit/refund exigent un Idempotency-Key (400 IDEMPOTENCY_KEY_REQUIRED sinon). Une erreur transport émetteur (TITREO_HTTP/TITREO_UNKNOWN) est renvoyée en 502 non mis en cache (donc rejouable), les refus métier déterministes en 200.
Bootstrap SDK & webhooks PSP
Section intitulée « Bootstrap SDK & webhooks PSP »| Fichier | Méthode + Path | Use case | Auth |
|---|---|---|---|
routes/bootstrap.routes.ts |
GET /v1/sessions/:id/bootstrap |
generateManifest (+ lecture merchant/pspConfigs) |
session-action:session:read |
routes/webhooks.routes.ts |
POST /v1/webhooks/:providerType/:merchantId |
handlePspWebhook |
public (signature vérifiée dans le use case) |
Espace marchand (/v1/merchants/me/*)
Section intitulée « Espace marchand (/v1/merchants/me/*) »| Fichier | Méthode + Path | Use case | Auth |
|---|---|---|---|
routes/merchants-me.routes.ts |
GET /v1/merchants/me |
getMerchant |
— |
routes/merchants-me.routes.ts |
GET /v1/merchants/me/onboarding |
getMerchantOnboardingState |
— |
routes/merchants-me.routes.ts |
PUT /v1/merchants/me |
updateMerchant (+ audit) |
scope:webhooks:write |
routes/api-keys.routes.ts |
POST /v1/merchants/me/api-keys |
createApiKey (+ audit) |
scope:api_keys:write |
routes/api-keys.routes.ts |
GET /v1/merchants/me/api-keys |
listApiKeys |
scope:api_keys:read |
routes/api-keys.routes.ts |
DELETE /v1/merchants/me/api-keys/:id |
revokeApiKey (+ audit) |
scope:api_keys:write |
routes/branding.routes.ts |
GET /v1/merchants/me/branding |
getBranding |
scope:branding:read |
routes/branding.routes.ts |
PUT /v1/merchants/me/branding |
updateBranding (+ audit) |
scope:branding:write |
routes/psp-configs.routes.ts |
GET /v1/merchants/me/psp-configs |
listPspConfigs |
scope:psp:read |
routes/psp-configs.routes.ts |
GET /v1/merchants/me/psp-configs/:provider |
getPspConfig |
scope:psp:read |
routes/psp-configs.routes.ts |
PUT /v1/merchants/me/psp-configs/:provider |
upsertPspConfig (+ valid. format + audit) |
scope:psp:write |
routes/psp-configs.routes.ts |
DELETE /v1/merchants/me/psp-configs/:provider |
deletePspConfig (+ audit) |
scope:psp:write |
routes/gift-configs.routes.ts |
GET /v1/merchants/me/gift-configs |
listGiftConfigs |
scope:psp:read |
routes/gift-configs.routes.ts |
GET /v1/merchants/me/gift-configs/:provider |
getGiftConfig |
scope:psp:read |
routes/gift-configs.routes.ts |
PUT /v1/merchants/me/gift-configs/:provider |
upsertGiftConfig (vérification en direct + audit), 10 requêtes par minute |
scope:psp:write |
routes/gift-configs.routes.ts |
DELETE /v1/merchants/me/gift-configs/:provider |
deleteGiftConfig (+ audit) |
scope:psp:write |
routes/merchant-users.routes.ts |
GET /v1/merchants/me/users |
listMerchantUsers |
scope:users:read |
routes/merchant-users.routes.ts |
POST /v1/merchants/me/users/invites |
inviteMerchantUser (+ audit) |
scope:users:write |
routes/merchant-users.routes.ts |
DELETE /v1/merchants/me/users/invites/:id |
revokeMerchantInvite (+ audit) |
scope:users:write |
routes/merchant-users.routes.ts |
PATCH /v1/merchants/me/users/:id |
updateMerchantUser (+ audit) |
scope:users:write |
routes/webhook-deliveries.routes.ts |
GET /v1/merchants/me/webhook-deliveries |
repo.list (port direct) |
scope:webhooks:read |
routes/webhook-deliveries.routes.ts |
GET /v1/merchants/me/webhook-deliveries/:id |
repo.findById |
scope:webhooks:read |
routes/webhook-deliveries.routes.ts |
POST /v1/merchants/me/webhook-deliveries/:id/replay |
outbox.enqueue (MERCHANT_WEBHOOK) |
scope:webhooks:write |
webhook-deliveries est l’un des rares modules qui appelle des ports directement (WebhookDeliveriesRepository, OutboxRepository) plutôt qu’un use case dédié : le « replay » fabrique une entrée d’outbox MERCHANT_WEBHOOK avec une idempotencyKey déterministe. La pagination utilise un curseur opaque base64url { createdAt, id } (helpers encodeCursor/decodeCursor).
Espace admin Titreo (/v1/admin/* — scope admin)
Section intitulée « Espace admin Titreo (/v1/admin/* — scope admin) »| Fichier | Méthode + Path | Use case | Auth |
|---|---|---|---|
routes/admin-merchants.routes.ts |
POST /v1/admin/merchants |
createMerchant (+ audit) |
scope:admin |
routes/admin-merchants.routes.ts |
GET /v1/admin/merchants |
listMerchants |
scope:admin |
routes/admin-merchants.routes.ts |
GET /v1/admin/merchants/:id |
getMerchant |
scope:admin |
routes/admin-merchants.routes.ts |
PATCH /v1/admin/merchants/:id |
adminUpdateMerchant (+ audit) |
scope:admin |
routes/admin-api-keys.routes.ts |
POST /v1/admin/merchants/:id/api-keys |
createApiKey (+ audit) |
scope:admin |
routes/admin-api-keys.routes.ts |
GET /v1/admin/merchants/:id/api-keys |
listApiKeys |
scope:admin |
routes/admin-api-keys.routes.ts |
DELETE /v1/admin/merchants/:id/api-keys/:keyId |
revokeApiKey (+ audit) |
scope:admin |
routes/admin-psp-configs.routes.ts |
GET /v1/admin/merchants/:id/psp-configs |
listPspConfigs |
scope:admin |
routes/admin-psp-configs.routes.ts |
DELETE /v1/admin/merchants/:id/psp-configs/:provider |
deletePspConfig (+ audit) |
scope:admin |
routes/gift-configs.routes.ts |
GET /v1/admin/merchants/:id/gift-configs |
listGiftConfigs |
scope:admin |
routes/gift-configs.routes.ts |
DELETE /v1/admin/merchants/:id/gift-configs/:provider |
deleteGiftConfig (+ audit) |
scope:admin |
routes/events.routes.ts |
GET /v1/admin/events |
listEvents — journal métier, tous marchands |
scope:admin |
routes/events.routes.ts |
GET /v1/admin/sessions |
listSessions — sessions, tous marchands ou merchantId |
scope:admin |
routes/admin-logs.routes.ts |
GET /v1/admin/logs |
searchLogs |
scope:admin |
routes/admin-logs.routes.ts |
GET /v1/admin/logs/histogram |
logHistogram |
scope:admin |
routes/admin-logs.routes.ts |
GET /v1/admin/logs/labels |
logServices |
scope:admin |
routes/admin-logs.routes.ts |
GET /v1/admin/traces/:correlationId |
getTrace |
scope:admin |
routes/admin-logs.routes.ts |
GET /v1/admin/sessions/:id/trace |
getTrace |
scope:admin |
routes/admin-users.routes.ts |
GET /v1/admin/users |
repo.listAll (port direct) |
scope:admin |
routes/admin-users.routes.ts |
POST /v1/admin/users |
createAdminUser (+ audit) |
scope:admin |
routes/audit-logs.routes.ts |
GET /v1/audit-logs |
listAuditLogs |
— (admin voit tout / cible merchantId, marchand voit le sien) |
L’administration lit et retire les accès cartes cadeaux d’un marchand ; elle ne les saisit jamais à sa place. Les routes de logs sont décrites sur Logs techniques.
Authentification utilisateur (/v1/auth/* — public, par cookie)
Section intitulée « Authentification utilisateur (/v1/auth/* — public, par cookie) »routes/auth.routes.ts gère le cycle de vie des sessions navigateur (Console marchand & Dashboard admin). Toutes ces routes sont publiques (préfixe /v1/auth/) avec rate-limit serré.
| Méthode + Path | Use case | Notes |
|---|---|---|
POST /v1/auth/signup |
signupMerchant |
rate-limit 3/min ; verificationLink exposé seulement si exposeVerificationLink |
POST /v1/auth/verify-email |
verifyMerchantEmail |
3/min |
POST /v1/auth/accept-invite |
acceptMerchantInvite |
3/min |
POST /v1/auth/login |
loginMerchant |
5/min ; pose le cookie tep_session |
POST /v1/auth/admin/login |
loginAdmin |
5/min ; pose le cookie tep_session |
POST /v1/auth/logout |
logoutUser |
efface le cookie |
GET /v1/auth/me |
(inline) lit req.userSession |
401 si pas de session |
Le cookie est posé par setSessionCookie : httpOnly, secure (selon config), sameSite: 'strict', path: '/', expiration alignée sur le token. Voir Sécurité.
4. Schémas Zod & sérialiseurs
Section intitulée « 4. Schémas Zod & sérialiseurs »schemas/sessions.ts
Section intitulée « schemas/sessions.ts »Ce fichier centralise les schémas Zod de la zone sessions (les autres modules définissent leurs schémas inline). Il sert à la fois à valider l’entrée et à typer/sérialiser la sortie via le ZodTypeProvider.
| Schéma | Usage |
|---|---|
CreateSessionBodySchema |
corps de POST /v1/sessions (reference, totalAmount entier positif, currency ∈ EUR/CHF/USD/GBP, metadata?) |
SessionIdParamsSchema / SessionLegParamsSchema |
params :id / :id + :legId |
AddGiftLegBodySchema |
ajout d’un leg carte cadeau (cardToken, cardLast4, emitter, availableBalance, debitAmount?, cvv?) |
SetPspLegBodySchema |
ajout d’un leg PSP (providerType, paymentMethod) |
LegResponseSchema / SessionResponseSchema |
DTO de sortie (forme exacte produite par les sérialiseurs) |
RefundBodySchema |
corps strict de refund : reason?, legIds?, strategy? (déprécié) |
SubmitResponseSchema / RefundResponseSchema |
retours submit / refund |
ListSessionsQuerySchema / ListSessionsResponseSchema |
filtres de liste (status, reference, from/to, limit, cursor) + page |
ErrorResponseSchema |
enveloppe d’erreur partagée { code, message, details? } |
Les types SessionResponse et LegResponse sont inférés (z.infer<...>) et importés par les sérialiseurs : la forme du DTO et son schéma de validation ne peuvent pas diverger.
serializers.ts
Section intitulée « serializers.ts »Deux fonctions pures convertissent les entités domaine en DTO HTTP. C’est la frontière de sortie : aucune entité du domaine ne fuit telle quelle vers le client.
serializeLeg(leg: PaymentLeg): LegResponse— appelleleg.snapshot()puis aplatitamount/currency, expose les champs provider, balances,requiresAction/actionUrl, et convertit les dates en ISO 8601.serializeSession(session: PaymentSession): SessionResponse— aplatittotalAmount/remainingAmount, exposestatus,metadata, dates ISO, et mappesession.legsviaserializeLeg.
// Extrait : la sortie HTTP est toujours un DTO, jamais l'agrégat brut.export function serializeSession(session: PaymentSession): SessionResponse { return { id: session.id, totalAmount: session.totalAmount.amount, // Money -> entier remainingAmount: session.remainingAmount.amount, status: session.status, expiresAt: session.expiresAt.toISOString(), // Date -> ISO legs: session.legs.map(serializeLeg), // ... }}Un chemin d’appel complet, commenté
Section intitulée « Un chemin d’appel complet, commenté »POST /v1/sessions/:id/refund (refund croisé d'une session)│├─ onRequest request-context -> pose req.correlationId, ouvre le contexte ambiant├─ onRequest user-session -> (si cookie) pose req.userSession├─ onRequest auth -> valide Bearer / synthétise -> req.auth { merchantId, scopes }├─ onRequest audit -> expose req.audit(...)├─ preHandler call-log -> pose sessionId dans le contexte ambiant├─ preHandler auth -> (client token) vérifie sessionId == :id, sinon 403├─ preHandler idempotency -> si Idempotency-Key déjà vu : rejoue la réponse 2xx├─ preHandler requireScope('sessions:write') -> 403 si scope manquant│├─ handler (sessions.routes.ts)│ 1. validation Zod du body (RefundBodySchema) [parse]│ 2. assertOwnedSession(...) -> 404 si pas le bon marchand│ 3. container.useCases.refundSession.execute() [use case : orchestration + outbox]│ 4. req.audit({ action: 'session.refund', ... })│ 5. reply.send({ sessionId, status, refundedLegIds, refundedAmount, currency, outboxIds }) [sérialise]│├─ onSend idempotency -> persiste la réponse 2xx pour rejeu futur└─ onResponse call-log -> écrit la ligne http_inPour la suite de l’exécution (use case → domaine → outbox → workers), voir L’orchestrateur, Outbox & orchestration, Machines à états et Workers. Pour la persistance, voir Couche Infrastructure et Base de données. Vue d’ensemble : Parcours d’appel.