Aller au contenu

Couche Application

La couche Application (api/src/application/) est le chef d’orchestre du backend. Elle ne contient ni règle métier pure (ça, c’est la Couche Domain), ni détail technique comme SQL ou HTTP (ça, c’est la Couche Infrastructure et la Couche HTTP).

Son rôle tient en trois verbes :

  1. Coordonner — un use case charge une entité du domaine, lui demande de changer d’état, puis persiste. Exemple : « ajouter une carte cadeau à une session », « rembourser une session ».
  2. Décrire ses besoins — elle déclare des ports (interfaces TypeScript) pour tout ce qu’elle ne sait pas faire elle-même : lire en base, appeler un PSP, signer un manifest, envoyer un email. Elle ne sait pas comment c’est fait, seulement ce qu’elle attend.
  3. Rester pure — aucun import de Fastify, d’un driver SQL ou de node:fetch ici. Tout passe par injection de dépendances dans le constructeur. C’est ce qui rend les use cases testables sans base ni réseau (voir Architecture hexagonale).
flowchart LR
  HTTP["HTTP (handlers)"] --> UC["Use case (application)"]
  UC --> DOM["Domain (entités pures)"]
  UC --> PORTS["Ports (interfaces)"]
  PORTS -.implémentés par.-> INFRA["Infrastructure (adapters)"]

Tous les use cases suivent le même squelette : une classe avec un constructeur qui reçoit ses dépendances (les ports), et une unique méthode execute(input). Pas d’état partagé, pas de méthode statique magique.

Voici api/src/application/sessions/create-session.ts, dépouillé à l’essentiel :

export class CreateSession {
constructor(
private readonly uow: UnitOfWork,
private readonly merchants: MerchantRepository,
private readonly clock: Clock,
private readonly ids: IdGenerator,
) {}
async execute(input: CreateSessionInput): Promise<PaymentSession> {
const merchantId = id<'merchant'>('merchant', input.merchantId) as MerchantId
const merchant = await this.merchants.findById(merchantId)
if (!merchant || !merchant.active) {
throw new ValidationError('Merchant not found or inactive', { merchantId: input.merchantId })
}
return this.uow.run(async ({ sessions }) => {
const existing = await sessions.findByReference(merchantId, input.reference)
if (existing) return existing // idempotence métier sur (merchant, reference)
const now = this.clock.now()
const session = PaymentSession.create({ /* … */ }) // le DOMAINE crée l'entité
await sessions.save(session)
return session
})
}
}

Les invariants à retenir :

  • Injection par constructeur : uow, merchants, clock, ids sont des ports. Aucun new FirebirdRepository() dans le use case.
  • clock et ids sont des ports : on n’appelle jamais new Date() ni crypto.randomUUID() directement → le temps et les identifiants sont déterministes en test.
  • La logique métier reste dans le domaine : PaymentSession.create(...), session.addGiftLeg(...), session.submit(...). Le use case séquence, l’entité décide et valide.
  • Les erreurs sont des erreurs domaine : ValidationError, InvalidStateTransition (depuis domain/shared/errors.js), traduites en HTTP par la couche au-dessus.
  • L’input est un type plat (souvent des string/number bruts) ; le use case le « brande » en identifiants typés (MerchantId, SessionId) via id<'…'>(…).

Use cases « session » (le cœur de l’orchestration)

Section intitulée « Use cases « session » (le cœur de l’orchestration) »

Ces use cases pilotent le cycle de vie d’une PaymentSession et de ses PaymentLeg. La plupart écrivent dans la base et déposent une intention dans l’outbox (voir plus bas). Détail des transitions d’état dans Machines à états.

Fichier (api/src/application/sessions/) Classe Ce qu’il orchestre
create-session.ts CreateSession Vérifie le marchand actif, crée la session (TTL = sessionTtlMinutes du marchand). Idempotent sur (merchantId, reference) : un appel répété renvoie la session existante.
get-session.ts GetSession Lecture seule d’une session par id (renvoie null si absente).
list-sessions.ts ListSessions Liste paginée par marchand (filtres statut/référence/dates), pagination cursor base64url encodée/décodée ici (encodeCursor/decodeCursor).
add-gift-leg.ts AddGiftLeg Crée un PaymentLeg carte cadeau (montant plafonné au min(solde, reste à couvrir)), l’attache à la session, enqueue DEBIT_GIFT dans l’outbox (idempotency debit:<legId>).
set-psp-leg.ts SetPspLeg Pose le leg PSP couvrant le reste à payer. Vérifie qu’une config PSP active existe pour le marchand (MERCHANT_PSP_NOT_CONFIGURED sinon). N’enqueue rien : l’autorisation part au submit.
remove-leg.ts RemoveLeg Retire un leg. Si c’est un leg cadeau déjà CAPTURED, ne le supprime pas en base mais enqueue CANCEL_GIFT (compensation) ; sinon retrait direct.
submit-session.ts SubmitSession Valide la session (session.submit). S’il y a un leg PSP → enqueue AUTHORIZE_PSP et passe PROCESSING. Sinon, si tous les legs sont CAPTUREDCOMPLETED.
confirm-psp-action.ts ConfirmPspAction Après une action 3-D Secure côté client (leg PSP HELD), efface l’exigence d’action et enqueue CAPTURE_PSP (idempotency capture:<legId>). Renvoie ALREADY_CAPTURED/ENQUEUED/NO_OP.
check-gift-balance.ts CheckGiftBalance Interroge le solde d’une carte dans le contexte d’une session via le GiftCardRegistry. Valide/normalise/masque le numéro. Renvoie un token + 4 derniers chiffres réutilisables par add-gift-leg.
refund-session.ts RefundSession Rembourse les legs CAPTURED selon une strategy (gift_first / cb_first / all), enqueue un REFUND par leg (idempotency refund:<sessionId>:<legId>), recalcule le statut.

L’orchestrateur asynchrone qui consomme les entrées d’outbox produites ci-dessus, api/src/application/workers/process-outbox-entry.ts, est documenté à part : voir L’orchestrateur. Le worker cleanup-expired-sessions.ts est couvert dans Workers.


Use cases « gift » (cartes cadeaux sans session)

Section intitulée « Use cases « gift » (cartes cadeaux sans session) »

API directe sur les cartes cadeaux externes (gateway Titreo / Mock), découplée de toute session. Tous passent par le GiftCardRegistry → un GiftCardProvider. L’idempotencyKey reçu (en-tête Idempotency-Key) sert aussi de ticket provider : double protection contre le re-débit.

Fichier (api/src/application/gift/) Classe / export Ce qu’il orchestre
lookup-gift-balance.ts LookupGiftBalance Lit le solde d’une carte (/v1/gift-cards/check-balance), sans session.
debit-gift-card.ts DebitGiftCard Débit immédiat (/v1/gift-cards/debit). TITREO_HTTP/TITREO_UNKNOWN = transport transitoire (5xx retentable) ; autres FAILED = refus métier déterministe (200).
refund-gift-card.ts RefundGiftCard Recrédit / reversal (/v1/gift-cards/refund) — couvre release (annulation) et refund (post-paiement).
gift-card-shared.ts normalizeCardNumber, isValidCardNumber, normalizeCvv, isValidCvv, maskCardNumber Helpers de validation/masquage partagés (pas un use case).

Fichier (api/src/application/webhooks/) Classe / export Ce qu’il orchestre
handle-psp-webhook.ts HandlePspWebhook Reçoit un webhook PSP : vérifie la signature (provider.verifyWebhook), déduplique via webhookEvents.markReceived, retrouve la session par authId, applique l’événement au leg PSP (succès/échec/capture/refund/dispute), déclenche la compensation des legs cadeaux (CANCEL_GIFT) si rollback, puis enqueue un webhook marchand si statut terminal.
merchant-webhook.ts buildMerchantPayload, signMerchantPayload, enqueueMerchantWebhook, eventForStatus, TERMINAL_EVENTS Helpers (pas une classe use case) : construisent et signent (HMAC-SHA256) le payload sortant vers le marchand, et l’enqueuent en outbox MERCHANT_WEBHOOK (idempotency webhook:<sessionId>:<event>).

Use cases « psp-configs » (configuration PSP par marchand)

Section intitulée « Use cases « psp-configs » (configuration PSP par marchand) »
Fichier (api/src/application/psp-configs/) Classe Ce qu’il orchestre
upsert-psp-config.ts UpsertPspConfig Crée/met à jour la config PSP d’un marchand (clé secrète, secret webhook, clé publique…). Reçoit un PspConfigValidatorFn injecté ; calcule les …Last4 pour affichage.
get-psp-config.ts GetPspConfig Lecture d’une config par (merchantId, providerType).
list-psp-configs.ts ListPspConfigs Liste les configs d’un marchand.
delete-psp-config.ts DeletePspConfig Supprime la config d’un provider pour un marchand.

Fichier (api/src/application/merchants/) Classe Ce qu’il orchestre
create-merchant.ts CreateMerchant Crée un marchand (TTL session 1..1440 min, branding vide par défaut). Refuse un id déjà existant.
get-merchant.ts GetMerchant Lecture d’un marchand par id.
list-merchants.ts ListMerchants Liste paginée (cursor) des marchands, filtre active.
update-merchant.ts UpdateMerchant Mise à jour côté marchand : valide nom, URL webhook (http/https), secret webhook (≥ 16 car.), TTL.
admin-update-merchant.ts AdminUpdateMerchant Variante admin (vérifie l’existence préalable du marchand).
get-onboarding-state.ts GetMerchantOnboardingState Calcule l’avancement d’onboarding (PSP configuré, clé API créée, webhook, branding, plugin installé) en croisant 3 repositories.

Fichier (api/src/application/api-keys/) Classe Ce qu’il orchestre
create-api-key.ts CreateApiKey Génère une clé tep_live_/tep_test_ (via un ApiKeyHasher injecté), vérifie que l’acteur détient déjà les scopes accordés (hasScope), n’expose le token en clair qu’une fois.
authenticate-api-key.ts AuthenticateApiKey Authentifie une requête : hash du token → findByHash, rejette révoquée/expirée, vérifie le marchand actif, met à jour lastUsed en fire-and-forget.
list-api-keys.ts ListApiKeys Liste les clés d’un marchand (sans le secret).
revoke-api-key.ts RevokeApiKey Révoque une clé (vérifie l’appartenance au marchand ; idempotent si déjà révoquée).

Les scopes (Scope, hasScope) viennent du domaine domain/auth/scope.ts. Voir Sécurité.


Use cases « auth » (sessions utilisateur console/admin)

Section intitulée « Use cases « auth » (sessions utilisateur console/admin) »

Authentification humaine (console marchand, dashboard admin) par session à cookie, distincte des clés API machine.

Fichier (api/src/application/auth/) Classe Ce qu’il orchestre
create-admin-user.ts CreateAdminUser Crée un utilisateur admin (mot de passe haché via PasswordHasher).
login-admin.ts LoginAdmin Login admin : vérifie le mot de passe, ouvre une UserSession, émet un token via UserSessionTokenService.
login-merchant.ts LoginMerchant Idem pour un utilisateur marchand.
logout-user.ts LogoutUser Révoque la session utilisateur associée au token.
resolve-user-session.ts ResolveUserSession Résout un token de cookie → utilisateur admin ou marchand (TTL glissant : 12 h admin, 24 h marchand). Pivot d’autorisation des routes console/admin.
signup-merchant.ts SignupMerchant Auto-inscription marchand : crée marchand + 1er utilisateur, envoie l’email de vérification (EmailSender) vers consoleBaseUrl.
verify-merchant-email.ts VerifyMerchantEmail Valide le token d’email reçu et marque l’utilisateur vérifié.

Use cases « merchant-users » (équipe d’un marchand)

Section intitulée « Use cases « merchant-users » (équipe d’un marchand) »
Fichier (api/src/application/merchant-users/) Classe Ce qu’il orchestre
invite-merchant-user.ts InviteMerchantUser Crée une invitation et envoie l’email d’invitation vers la console.
accept-merchant-invite.ts AcceptMerchantInvite Transforme une invitation en utilisateur (création + mot de passe haché).
revoke-merchant-invite.ts RevokeMerchantInvite Révoque une invitation en attente.
list-merchant-users.ts ListMerchantUsers Liste les membres et les invitations en attente d’un marchand.
update-merchant-user.ts UpdateMerchantUser Met à jour le rôle / l’état (désactivé) d’un membre.

Fichier Classe Ce qu’il orchestre
audit/write-audit-log.ts WriteAuditLog Écrit une entrée de journal d’audit (action, acteur, cible, IP, payload).
audit/list-audit-logs.ts ListAuditLogs Liste paginée (cursor) des entrées d’audit, filtres marchand/action/dates.
branding/get-branding.ts GetBranding Lit le branding (thème, logo) d’un marchand sous forme de BrandingDto.
branding/update-branding.ts UpdateBranding Met à jour le branding d’un marchand.

Fichier (api/src/application/bootstrap/) Classe Ce qu’il orchestre
generate-manifest.ts GenerateBootstrapManifest Construit le manifest de bootstrap signé envoyé au SDK client : résout le SDK + l’adapter PSP (et gift) via ClientAdapterRegistry, injecte le branding du marchand et la clé publique PSP, canonicalise et signe le payload via ManifestSigner. Ne pose aucun leg, ne touche pas l’outbox.

Les ports : tout ce que la couche Application réclame au monde extérieur

Section intitulée « Les ports : tout ce que la couche Application réclame au monde extérieur »

Un port est une interface (type/interface TypeScript) dans api/src/application/ports/. Le use case s’en sert ; un adapter d’infrastructure l’implémente. Aucune dépendance concrète n’apparaît jamais ici.

Port (ports/) Type/interface clé Rôle
session-repository.ts SessionRepository CRUD/lecture des PaymentSession : findById, findByReference, findByPspAuthId, save, list (paginé), listExpiredActive.
outbox-repository.ts OutboxRepository, OutboxAction, OutboxEntry File transactionnelle d’intentions : enqueue, claim (atomique PENDING→PROCESSING), markCompleted, markFailed, listReady. Cœur du pattern outbox.
merchant-repository.ts MerchantRepository, Merchant, MerchantBranding Marchands : findById, create, update, list. Porte aussi emptyBranding().
merchant-psp-config-repository.ts MerchantPspConfigRepository, MerchantPspConfig Configs PSP par marchand : findActive, findByProvider, upsert, listForMerchant, delete.
api-key-repository.ts ApiKeyRepository, ApiKey Clés API : findByHash, findById, insert, listForMerchant, markRevoked, touchLastUsed.
audit-log-repository.ts AuditLogRepository, AuditLog Journal d’audit : insert, list (paginé).
admin-user-repository.ts AdminUserRepository Utilisateurs admin : findById, findByEmail, insert, updateLastLogin.
merchant-user-repository.ts MerchantUserRepository Utilisateurs marchands : recherche, vérif email, maj mot de passe / rôle / désactivation.
merchant-user-invite-repository.ts MerchantUserInviteRepository Invitations d’équipe : findByTokenHash, insert, markAccepted, markRevoked, listPendingByMerchant.
user-session-repository.ts UserSessionRepository Sessions humaines (cookie) : insert, findByTokenHash, updateLastSeen, revoke, deleteExpired.
webhook-deliveries-repository.ts WebhookDeliveriesRepository Historique des livraisons de webhooks marchands (statut, retries), pour observabilité.
webhook-events-repository.ts WebhookEventsRepository Déduplication des événements PSP entrants : markReceived (renvoie duplicate), markProcessed.
Port Type clé Rôle
unit-of-work.ts UnitOfWork, TransactionalRepos Exécute un bloc dans une seule transaction DB, en exposant { sessions, outbox, webhookEvents? }. C’est le mécanisme qui garantit l’atomicité « écrire la session et enqueue l’outbox » (voir ci-dessous).
Port Type clé Rôle
payment-provider.ts PaymentProvider, AuthorizeResult, WebhookEvent Contrat d’un PSP : authorize, capture, void, refund, confirmAction, verifyWebhook, mapNativeError + capabilities. Voir Contrats.
psp-registry.ts PspRegistry Résout un PaymentProvider pour (providerType, merchantId) : get, has.
gift-card-provider.ts GiftCardProvider, BalanceResult, DebitResult Contrat d’un émetteur de cartes cadeaux : checkBalance, debit, cancel, refund.
gift-card-registry.ts GiftCardRegistry Résout un GiftCardProvider par type : get, has.
client-adapter-registry.ts ClientAdapterRegistry Résout les specs d’adapters côté client pour le manifest : resolveSdk, resolvePsp, resolveGiftCard.
manifest-signer.ts ManifestSigner Signe le manifest de bootstrap : sign(message), keyId.
webhook-sender.ts WebhookSender Émet un webhook HTTP sortant vers le marchand : sendWebhookSendResult.
email-sender.ts EmailSender Envoie un email (send(message)) — vérif email, invitations.
Port Type clé Rôle
clock.ts Clock now(): Date. Temps injecté → tests déterministes, jamais new Date() dans un use case.
id-generator.ts IdGenerator next(): string. Génération d’identifiants injectée.
idempotency-store.ts IdempotencyStore, IdempotencyRecord Cache de réponses idempotentes HTTP : save, deleteExpired (+ lecture). Voir Idempotence.
password-hasher.ts PasswordHasher hash / verify de mots de passe.
user-session-token-service.ts UserSessionTokenService Génère/hash les tokens de session cookie : generate, hash.

UnitOfWork + outbox : écrire l’état et l’intention dans la même transaction

Section intitulée « UnitOfWork + outbox : écrire l’état et l’intention dans la même transaction »

C’est le pattern central de l’orchestration. Quand un use case modifie une session et déclenche une action externe (débiter une carte, autoriser un PSP, notifier le marchand), il ne fait pas l’appel réseau lui-même. Il fait deux écritures DB dans une seule transaction :

  1. sessions.save(session) — le nouvel état métier.
  2. outbox.enqueue({...}) — une intention d’action externe (« débite cette carte »).

Soit les deux sont commitées ensemble, soit aucune. Un worker séparé (L’orchestrateur) lira l’outbox plus tard et exécutera l’appel réseau réel. C’est ce qui rend le système résilient aux crashs : aucune action externe ne peut être « perdue » ou « jouée sans son état ».

sequenceDiagram
  participant UC as "Use case (ex: AddGiftLeg)"
  participant UoW as "UnitOfWork (1 transaction DB)"
  participant SR as "sessions (repo)"
  participant OB as "outbox (repo)"
  UC->>UoW: run(work)
  UoW->>SR: findById(sessionId)
  UC->>UC: session.addGiftLeg(leg, now)
  UC->>OB: enqueue(action DEBIT_GIFT, idempotencyKey debit:legId)
  UC->>SR: save(session)
  UoW-->>UC: COMMIT (état + intention, atomique)

Extrait réel de add-gift-leg.ts (chemin d’appel commenté) :

return this.uow.run(async ({ sessions, outbox }) => {
const session = await sessions.findById(sessionId) // (1) charge l'agrégat
// … validations de montant …
session.addGiftLeg(leg, now) // (2) le DOMAINE applique la transition
const outboxId = this.ids.next()
await outbox.enqueue({ // (3) intention d'action externe
id: outboxId,
sessionId,
legId,
action: 'DEBIT_GIFT', // => le worker débitera la carte
payload: { token: input.cardToken, amount: debitAmount, emitter: input.emitter },
idempotencyKey: `debit:${legId}`, // clé d'idempotence stable (rejeu sûr)
now,
})
await sessions.save(session) // (4) état persisté DANS la même tx
return { legId, amount: debitAmount, outboxId }
}) // COMMIT atomique en sortie de run()

Les use cases en lecture seule (GetSession, ListSessions, GetMerchant, ListApiKeys…) n’utilisent pas UnitOfWork : ils prennent directement le repository concerné et appellent une méthode de lecture. UnitOfWork n’est requis que lorsqu’il faut une atomicité écriture-état + écriture-outbox (ou plusieurs écritures cohérentes).


  • Couche Domain — les entités (PaymentSession, PaymentLeg) et leurs invariants que les use cases pilotent.
  • L’orchestrateur — le worker qui consomme l’outbox et appelle réellement les providers.
  • Couche Infrastructure — les adapters qui implémentent ces ports (Firebird, PSP, Titreo, email…).
  • Couche HTTP — les handlers qui parsent la requête, appellent un use case et sérialisent.
  • Parcours d’appel — un flow complet de bout en bout, HTTP → use case → outbox → worker.