Aller au contenu

Couche Domain

La couche Domain (api/src/domain/) contient le coeur metier de Titreo E-Pay : les regles qui restent vraies quelles que soient la base de donnees, le framework HTTP ou le PSP branche. C est du TypeScript pur : zero import de Fastify, de driver Firebird ou de SDK Stripe. On y trouve uniquement des classes, des types et des fonctions qui modelisent ce qu est un paiement orchestre.

Concretement, deux entites portent toute la logique :

  • PaymentSession — une intention de paiement d un montant total, decomposee en plusieurs « jambes ».
  • PaymentLeg — une jambe : soit une carte cadeau (gift_card), soit un moyen de paiement classique via PSP (psp). Une session = N cartes cadeaux + au plus 1 leg PSP pour le reste.

Autour de ces entites gravitent des value objects (Money, Id, Currency), les machines a etats (statuts session et leg + transitions autorisees), une hierarchie d erreurs typees, un catalogue d erreurs PSP normalisees, et quelques modules transverses (auth, branding marchand, manifest bootstrap).

La regle qui structure toute la classe PaymentSession est le montant restant (remainingAmount) :

remainingAmount = totalAmount − somme des legs gift_card "actifs"

Un leg gift_card est « actif » seulement s il est en statut PENDING ou CAPTURED. Le leg PSP, lui, doit couvrir exactement ce montant restant. Cet invariant est verifie a chaque ajout de leg et au moment du submit().

flowchart TD
  S["session/ (entites + machines)"]
  SH["shared/ (value objects + erreurs)"]
  M["merchant/ (branding)"]
  A["auth/ (scopes + users)"]
  B["bootstrap/ (manifest)"]
  S --> SH
  M --> SH
  A --> SH
  S -.->|"importe Money, Id, errors"| SH
Sous-dossier Role
session/ Entites PaymentSession et PaymentLeg, plus les deux machines a etats (session-status, leg-status)
shared/ Value objects (Money, Id, Currency), hierarchie d erreurs (errors) et catalogue d erreurs PSP normalisees (error-catalog)
merchant/ Sanitisation du branding marchand (tokens de theme, logo)
auth/ Scopes d API, actions de tokens client, modeles AdminUser / MerchantUser
bootstrap/ Types du manifest de bootstrap SDK + canonicalisation JSON pour signature

Fichier Role Exports / methodes cles
session/session.ts Aggregat racine PaymentSession : cycle de vie, gestion des legs, invariant remainingAmount classe PaymentSession ; types SessionSnapshot, CreateSessionInput, SessionMetadata
session/leg.ts Entite PaymentLeg : factories gift/psp + transitions pilotees par le provider classe PaymentLeg ; types LegSnapshot, CreateGiftLegInput, CreatePspLegInput
session/session-status.ts Type des statuts session + derivation depuis les statuts des legs type SessionStatus ; deriveSessionStatus() ; TERMINAL_SESSION_STATUSES
session/leg-status.ts Type des statuts leg + tables de transitions gift/psp + predicats type LegStatus, LegType ; assertLegTransition() ; isLegFailed/isLegRolledBack/isLegRollingBack/isLegSuccessful ; TERMINAL_LEG_STATUSES

Constructeur prive : on ne cree jamais une session avec new. Deux portes d entree :

  • PaymentSession.create(input: CreateSessionInput) — cree une session neuve au statut CREATED. Valide la reference (longueur 1..128), que totalAmount est positif, et que expiresAt est dans le futur (compare a input.now). Demarre sans aucun leg.
  • PaymentSession.fromSnapshot(snapshot: SessionSnapshot) — rehydrate une session depuis le stockage. Aucune validation : on fait confiance a l etat persiste. Copie defensivement metadata et legs.

snapshot() fait l operation inverse : produit un SessionSnapshot immuable (copies de metadata et legs) que la couche infra serialise vers Firebird.

Getter Calcul
giftLegs tous les legs de type gift_card
pspLeg l unique leg psp ou null
giftSubtotal somme des montants des legs gift dont le statut est PENDING ou CAPTURED
remainingAmount totalAmount.subtract(giftSubtotal)
currency devise du totalAmount

Ces trois methodes ne sont autorisees que si le statut est CREATED ou COLLECTING (garde assertCollectingMutable()), sinon InvalidStateTransition.

  • addGiftLeg(leg, now) — verifie que le leg est bien gift_card, que son sessionId correspond, que la devise matche (CurrencyMismatch sinon), et que le sous-total projete ne depasse pas totalAmount. Pousse le leg et passe CREATED → COLLECTING si c est le premier leg.
  • setPspLeg(leg, now) — verifie le type psp, qu il n y a pas deja de leg PSP, la devise, et surtout que leg.amount egale exactement remainingAmount. Un seul leg PSP par session.
  • removeLeg(legId, now) — retire un leg en statut PENDING ou terminal ; refuse de retirer un leg en etat actif intermediaire.
stateDiagram-v2
  [*] --> CREATED
  CREATED --> COLLECTING: "addGiftLeg / setPspLeg (1er leg)"
  COLLECTING --> PROCESSING: "submit(now)"
  PROCESSING --> COMPLETED: "markCompleted(now)"
  PROCESSING --> ROLLING_BACK: "startRollback(now)"
  COLLECTING --> ROLLING_BACK: "startRollback(now)"
  ROLLING_BACK --> FAILED: "markFailed(now)"
  CREATED --> EXPIRED: "expire(now)"
  COLLECTING --> EXPIRED: "expire(now)"
  PROCESSING --> EXPIRED: "expire(now)"
  • submit(now) — exige le statut COLLECTING. Verifie que la somme de TOUS les legs egale totalAmount, et que si remainingAmount > 0 il existe un leg PSP. Passe a PROCESSING. C est la barriere avant tout appel provider.
  • markCompleted(now) — exige PROCESSING et que tous les legs soient CAPTURED. Passe a COMPLETED.
  • startRollback(now) — depuis PROCESSING ou COLLECTING, passe a ROLLING_BACK (declenche la compensation des legs deja captures).
  • markFailed(now) — depuis ROLLING_BACK, passe a FAILED.
  • expire(now) — passe a EXPIRED depuis tout etat non terminal (refuse COMPLETED, FAILED, EXPIRED).

Methode cle de l orchestration : delegue a deriveSessionStatus() le calcul du statut a partir des statuts des legs, et ne le change (touch(now)) que s il differe. C est ce qu appelle l orchestrateur apres chaque retour provider pour faire avancer la session sans dupliquer la logique de derivation.

Meme pattern : constructeur prive sur un state: LegSnapshot, factories statiques.

  • PaymentLeg.createGift(input: CreateGiftLegInput) — montant positif obligatoire ; cardLast4 doit faire exactement 4 caracteres alphanumeriques. Statut initial PENDING. Champs gift renseignes (cardToken, cardLast4, emitter, giftCvv optionnel), champs PSP a null.
  • PaymentLeg.createPsp(input: CreatePspLegInput) — montant positif obligatoire. Statut initial PENDING. Champs PSP renseignes (paymentMethod), champs gift a null.
  • PaymentLeg.fromSnapshot(snapshot) / snapshot() — rehydratation / serialisation (copie superficielle de state).

Le LegSnapshot porte un superset de colonnes couvrant les deux types (gift et psp) plus les horodatages d evenements (capturedAt, canceledAt, releasedAt, refundedAt) et l erreur normalisee (errorCode, errorMessage).

Toutes passent par transitionTo(next, now) qui appelle assertLegTransition() (machine a etats) avant de muter. Les methodes assertGift() / assertPsp() garantissent qu on n appelle pas une transition gift sur un leg PSP et inversement.

Methode Type Effet
markGiftDebited({...}) gift → CAPTURED ; enregistre transactionId, providerRef, balanceBefore/After, capturedAt
markGiftDebitFailed({...}) gift → DEBIT_FAILED ; pose errorCode/errorMessage
startGiftCancel(now) gift → CANCELING
markGiftCanceled(now) gift → CANCELED ; pose canceledAt
markPspHeld({...}) psp → HELD ; pose authId, providerRef, efface requiresAction
markPspRequiresAction({...}) psp → HELD avec requiresAction = true + actionUrl (ex: 3DS)
clearRequiresAction(now) psp efface requiresAction/actionUrl sans changer le statut
markPspHoldFailed({...}) psp → HOLD_FAILED
markPspCaptured(now) psp → CAPTURED ; pose capturedAt
markPspCaptureFailed({...}) psp → CAPTURE_FAILED
startPspRelease(now) psp → RELEASING
markPspReleased(now) psp → RELEASED ; pose releasedAt
startRefund(now) gift+psp → REFUNDING
markRefunded(now) gift+psp → REFUNDED ; pose refundedAt

Le type LegStatus enumere 12 statuts. Deux tables de transitions distinctes selon le type (GIFT_TRANSITIONS, PSP_TRANSITIONS) — un meme statut nomme (CAPTURED) n a pas les memes successeurs selon gift ou psp.

stateDiagram-v2
  state "gift_card" as G {
    [*] --> PENDING
    PENDING --> CAPTURED
    PENDING --> DEBIT_FAILED
    CAPTURED --> CANCELING
    CAPTURED --> REFUNDING
    CANCELING --> CANCELED
    REFUNDING --> REFUNDED
  }
  state "psp" as P {
    [*] --> PENDING2: PENDING
    PENDING2 --> HELD
    PENDING2 --> HOLD_FAILED
    HELD --> CAPTURED2: CAPTURED
    HELD --> CAPTURE_FAILED
    HELD --> RELEASING
    CAPTURED2 --> REFUNDING2: REFUNDING
    RELEASING --> RELEASED
    REFUNDING2 --> REFUNDED
  }

assertLegTransition(type, from, to) est le garde unique : il laisse passer from === to (idempotence), sinon leve InvalidStateTransition si to n est pas dans la liste autorisee. Predicats exposes, utilises par deriveSessionStatus :

  • isLegSuccessfulCAPTURED
  • isLegFailedDEBIT_FAILED / HOLD_FAILED / CAPTURE_FAILED
  • isLegRollingBackCANCELING / RELEASING
  • isLegRolledBackCANCELED / RELEASED

TERMINAL_LEG_STATUSES regroupe les 6 etats sans sortie.

Derivation du statut session — session-status.ts

Section intitulée « Derivation du statut session — session-status.ts »

deriveSessionStatus(legStatuses, fallback) applique, dans cet ordre, les regles suivantes :

// Ordre de priorite (court-circuit)
if (aucun leg) → 'CREATED' (ou 'EXPIRED' si fallback EXPIRED)
if (un leg failed OU un leg rolling) → 'ROLLING_BACK'
if (tous les legs rolled back) → 'FAILED'
if (tous REFUNDED) → 'FULLY_REFUNDED'
if (un REFUNDED ou REFUNDING) → 'PARTIALLY_REFUNDED'
if (tous CAPTURED) → 'COMPLETED'
sinon → fallback

SessionStatus compte 9 valeurs ; TERMINAL_SESSION_STATUSES = {COMPLETED, FAILED, FULLY_REFUNDED, EXPIRED}.


Fichier Role Exports cles
shared/money.ts Value object monetaire en centimes entiers classe Money (of, zero, add, subtract, equals, greaterThan, isZero, isPositive)
shared/id.ts Identifiants typés (branded types) type Id<Tag> + alias (SessionId, LegId, MerchantId, …) ; id(), uuid()
shared/currency.ts Devise validee (whitelist) type Currency ; currency(), isSupportedCurrency()
shared/errors.ts Hierarchie d erreurs domaine DomainError + sous-classes ; type DomainErrorCode
shared/error-catalog.ts Codes d erreurs PSP normalises + categorisation objet ErrorCode ; errorCategory, isRetriable, isUserActionable, interface NormalizedProviderError

Montants en centimes entiers uniquement. Money.of(amount, currency) rejette tout ce qui n est pas un entier (Number.isInteger) ou un montant negatif (ValidationError). Aucun flottant ne circule jamais dans le domaine.

  • add / subtract exigent la meme devise (CurrencyMismatch sinon) ; subtract refuse de produire un negatif (ValidationError).
  • Money est immuable : chaque operation retourne une nouvelle instance.

Id<Tag> = string & { readonly __idTag: Tag }. C est un branded type : a la compilation, un SessionId n est pas assignable a un LegId bien que les deux soient des string a l execution. Impossible de melanger un id de session et un id de leg par erreur.

  • id(tag, value) — validation generique (longueur 1..64).
  • uuid(tag, value) — exige le format UUID (regex). C est la fabrique utilisee pour les ids generes.

Alias exportes : SessionId, LegId, MerchantId, AdminUserId, MerchantUserId, UserSessionId.

Currency est aussi un branded type. currency(code) met en majuscules et verifie l appartenance a la whitelist {EUR, CHF, USD, GBP} (ValidationError sinon). isSupportedCurrency() est le test booleen non levant.

Toutes les erreurs metier derivent de DomainError, qui porte un code: DomainErrorCode et un objet details libre. La couche HTTP s appuie sur ce code pour mapper vers un statut et un format de reponse.

flowchart TD
  DE["DomainError (code, details)"]
  DE --> VE["ValidationError — VALIDATION_ERROR"]
  DE --> IST["InvalidStateTransition — INVALID_STATE_TRANSITION"]
  DE --> IV["InvariantViolation — INVARIANT_VIOLATION"]
  DE --> PE["ProviderError — PROVIDER_ERROR"]
  DE --> CM["CurrencyMismatch — CURRENCY_MISMATCH"]

DomainErrorCode comprend aussi INSUFFICIENT_BALANCE (code present dans l union, sans sous-classe dediee ici). CurrencyMismatch a un constructeur specialise (expected, actual) qui compose le message.

A ne pas confondre avec errors.ts. Ce fichier definit le vocabulaire stable dans lequel chaque adapter PSP traduit ses erreurs brutes — c est le contrat de normalisation cote infrastructure.

  • ErrorCode — objet constant (string union) regroupant erreurs provider (provider_unavailable, provider_timeout…), carte (card_declined, insufficient_funds…), session (session_expired, session_already_terminal…), idempotence, webhooks, et unknown.
  • errorCategory — mappe chaque code vers 'transient' | 'permanent' | 'user_actionable'.
  • isRetriable(code) → vrai si transient (le worker peut rejouer). isUserActionable(code) → vrai si user_actionable (ex: 3DS, requires_action).
  • NormalizedProviderError — interface { code, message, retriable, raw? } retournee par les adapters.

Fichier Role Exports cles
merchant/branding.ts Sanitisation stricte du branding marchand (anti-injection) sanitizeThemeTokens(), validateLogoUrl(), BRANDING_LIMITS

Le branding marchand (tokens de theme + logo) est rendu cote navigateur dans le SDK, donc il est traite comme une entree non fiable.

  • sanitizeThemeTokens(input) — ne garde que les cles au format custom property CSS (--foo-bar, regex TOKEN_KEY_RE) dont la valeur passe une whitelist de caracteres CSS surs (TOKEN_VALUE_RE, pas de ;, pas d accolades, pas d url). Rejette le payload s il depasse 8 Ko (ValidationError).
  • validateLogoUrl(input) — accepte null/vide, sinon exige soit une data:image/*;base64,... (png/jpeg/jpg/svg+xml/webp), soit une URL https://.... Plafond 200 Ko.
  • BRANDING_LIMITS expose les bornes (maxLogoBytes, maxThemeTokensBytes).

Fichier Role Exports cles
auth/scope.ts Scopes d API + actions autorisees aux tokens client navigateur SCOPES, type Scope ; CLIENT_TOKEN_ACTIONS, ClientTokenAction ; sessionActionRequiredScope ; isScope, parseScopes, serializeScopes, hasScope, clientTokenAllows
auth/user.ts Modeles AdminUser / MerchantUser + helpers email/mot de passe/roles types AdminUser, MerchantUser, AdminRole, MerchantRole ; normalizeEmail, assertPasswordStrength, isAdminRole, isMerchantRole ; constantes lockout

SCOPES liste les 15 scopes d API (sessions:read, sessions:write, sessions:client, psp:*, webhooks:*, api_keys:*, branding:*, users:*, merchant:write, admin). hasScope(granted, required) accorde tout si admin est present, sinon teste l appartenance.

CLIENT_TOKEN_ACTIONS est l allowlist explicite des actions qu un token emis cote navigateur peut declencher (lecture, ajout/retrait de leg, check balance, submit). Tout le reste (refund, list, create, renouvellement de token, confirm PSP) est serveur uniquement. sessionActionRequiredScope mappe chaque action client vers le scope serveur requis.

Types de comptes (admin plateforme et utilisateur marchand) avec champs anti-bruteforce : failedLoginAttempts, lockedUntil. Constantes LOGIN_LOCKOUT_THRESHOLD = 5 et LOGIN_LOCKOUT_DURATION_MS = 30 min. Helpers : normalizeEmail (trim + lowercase + regex + longueur), assertPasswordStrength (12..256 caracteres), isAdminRole / isMerchantRole.


Fichier Role Exports cles
bootstrap/manifest.ts Types du manifest de bootstrap SDK + canonicalisation JSON pour signature Ed25519 types BootstrapManifestPayload, SignedBootstrapManifest, SdkSpec, AdapterSpec, GiftAdapterSpec, ManifestUi, etc. ; fonction canonicalize()

BootstrapManifestPayload decrit le contrat de configuration envoye au loader SDK : schema_version, session_id, merchant_id, specs sdk / psp_adapter / gift_card_provider (url + integrity + version), ui, feature_flags, TTL, theme_tokens, merchant. SignedBootstrapManifest ajoute signature + key_id.

  • canonicalize(value) — serialise en JSON canonique : cles triees recursivement, sans espaces. C est le corps de message exact signe en Ed25519, garantissant que verificateur et signataire produisent la meme chaine. Toute divergence d ordre de cles casserait la signature.

Exemple : un client paie 50,00 EUR avec une carte cadeau de 30,00 EUR + le reste par CB.

// 1) Creation : session vide au statut CREATED
const session = PaymentSession.create({
id, merchantId, reference: 'CMD-123',
totalAmount: Money.of(5000, 'EUR'), // 50,00 EUR en centimes
expiresAt, now,
})
// 2) Ajout du leg gift : CREATED -> COLLECTING
// remainingAmount passe de 5000 a 2000
const gift = PaymentLeg.createGift({ id, sessionId: session.id, amount: Money.of(3000, 'EUR'), ... })
session.addGiftLeg(gift, now)
// 3) Ajout du leg PSP : doit egaler EXACTEMENT remainingAmount (2000)
const psp = PaymentLeg.createPsp({ id, sessionId: session.id, amount: Money.of(2000, 'EUR'), ... })
session.setPspLeg(psp, now) // sinon -> ValidationError
// 4) Submit : verifie somme legs == total, puis COLLECTING -> PROCESSING
session.submit(now)
// 5) L orchestrateur appelle les providers, puis sur chaque retour :
gift.markGiftDebited({ ... }) // PENDING -> CAPTURED
psp.markPspCaptured(now) // HELD -> CAPTURED
session.recomputeStatusFromLegs(now) // tous CAPTURED -> derive COMPLETED
session.markCompleted(now) // PROCESSING -> COMPLETED

L enchainement reel (transactions, idempotence, outbox) est decrit dans L orchestrateur et Outbox & orchestration. Le domaine ne fait qu imposer les regles ; il ne sait rien des providers ni de la base.