Couche Domain
A quoi sert cette couche
Section intitulée « A quoi sert cette couche »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).
Principe d invariant central
Section intitulée « Principe d invariant central »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().
Carte des sous-dossiers
Section intitulée « Carte des sous-dossiers »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 |
Sous-dossier session/
Section intitulée « Sous-dossier session/ »Tableau des fichiers
Section intitulée « Tableau des fichiers »| 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 |
PaymentSession (session/session.ts)
Section intitulée « PaymentSession (session/session.ts) »Constructeur prive : on ne cree jamais une session avec new. Deux portes d entree :
PaymentSession.create(input: CreateSessionInput)— cree une session neuve au statutCREATED. Valide lareference(longueur 1..128), quetotalAmountest positif, et queexpiresAtest dans le futur (compare ainput.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 defensivementmetadataetlegs.
snapshot() fait l operation inverse : produit un SessionSnapshot immuable (copies de metadata et legs) que la couche infra serialise vers Firebird.
Getters derives importants
Section intitulée « Getters derives importants »| 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 |
Mutations de collecte (avant submit)
Section intitulée « Mutations de collecte (avant submit) »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 biengift_card, que sonsessionIdcorrespond, que la devise matche (CurrencyMismatchsinon), et que le sous-total projete ne depasse pastotalAmount. Pousse le leg et passeCREATED → COLLECTINGsi c est le premier leg.setPspLeg(leg, now)— verifie le typepsp, qu il n y a pas deja de leg PSP, la devise, et surtout queleg.amountegale exactementremainingAmount. Un seul leg PSP par session.removeLeg(legId, now)— retire un leg en statutPENDINGou terminal ; refuse de retirer un leg en etat actif intermediaire.
Transitions de cycle de vie
Section intitulée « Transitions de cycle de vie »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 statutCOLLECTING. Verifie que la somme de TOUS les legs egaletotalAmount, et que siremainingAmount > 0il existe un leg PSP. Passe aPROCESSING. C est la barriere avant tout appel provider.markCompleted(now)— exigePROCESSINGet que tous les legs soientCAPTURED. Passe aCOMPLETED.startRollback(now)— depuisPROCESSINGouCOLLECTING, passe aROLLING_BACK(declenche la compensation des legs deja captures).markFailed(now)— depuisROLLING_BACK, passe aFAILED.expire(now)— passe aEXPIREDdepuis tout etat non terminal (refuseCOMPLETED,FAILED,EXPIRED).
recomputeStatusFromLegs(now)
Section intitulée « recomputeStatusFromLegs(now) »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.
PaymentLeg (session/leg.ts)
Section intitulée « PaymentLeg (session/leg.ts) »Meme pattern : constructeur prive sur un state: LegSnapshot, factories statiques.
PaymentLeg.createGift(input: CreateGiftLegInput)— montant positif obligatoire ;cardLast4doit faire exactement 4 caracteres alphanumeriques. Statut initialPENDING. Champs gift renseignes (cardToken,cardLast4,emitter,giftCvvoptionnel), champs PSP anull.PaymentLeg.createPsp(input: CreatePspLegInput)— montant positif obligatoire. Statut initialPENDING. Champs PSP renseignes (paymentMethod), champs gift anull.PaymentLeg.fromSnapshot(snapshot)/snapshot()— rehydratation / serialisation (copie superficielle destate).
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).
Transitions pilotees par le provider
Section intitulée « Transitions pilotees par le provider »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 |
Machines a etats — leg-status.ts
Section intitulée « Machines a etats — leg-status.ts »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 :
isLegSuccessful→CAPTUREDisLegFailed→DEBIT_FAILED/HOLD_FAILED/CAPTURE_FAILEDisLegRollingBack→CANCELING/RELEASINGisLegRolledBack→CANCELED/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 → fallbackSessionStatus compte 9 valeurs ; TERMINAL_SESSION_STATUSES = {COMPLETED, FAILED, FULLY_REFUNDED, EXPIRED}.
Sous-dossier shared/
Section intitulée « Sous-dossier shared/ »Tableau des fichiers
Section intitulée « Tableau des fichiers »| 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 |
Money (shared/money.ts)
Section intitulée « Money (shared/money.ts) »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/subtractexigent la meme devise (CurrencyMismatchsinon) ;subtractrefuse de produire un negatif (ValidationError).Moneyest immuable : chaque operation retourne une nouvelle instance.
Id et branded types (shared/id.ts)
Section intitulée « Id et branded types (shared/id.ts) »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 (shared/currency.ts)
Section intitulée « Currency (shared/currency.ts) »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.
Hierarchie d erreurs (shared/errors.ts)
Section intitulée « Hierarchie d erreurs (shared/errors.ts) »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.
Catalogue d erreurs PSP (shared/error-catalog.ts)
Section intitulée « Catalogue d erreurs PSP (shared/error-catalog.ts) »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, etunknown.errorCategory— mappe chaque code vers'transient' | 'permanent' | 'user_actionable'.isRetriable(code)→ vrai sitransient(le worker peut rejouer).isUserActionable(code)→ vrai siuser_actionable(ex: 3DS,requires_action).NormalizedProviderError— interface{ code, message, retriable, raw? }retournee par les adapters.
Sous-dossier merchant/
Section intitulée « Sous-dossier merchant/ »| 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, regexTOKEN_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)— acceptenull/vide, sinon exige soit unedata:image/*;base64,...(png/jpeg/jpg/svg+xml/webp), soit une URLhttps://.... Plafond 200 Ko.BRANDING_LIMITSexpose les bornes (maxLogoBytes,maxThemeTokensBytes).
Sous-dossier auth/
Section intitulée « Sous-dossier auth/ »| 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 |
scope.ts
Section intitulée « scope.ts »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.
Sous-dossier bootstrap/
Section intitulée « Sous-dossier bootstrap/ »| 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.
Parcours d appel commente
Section intitulée « Parcours d appel commente »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 CREATEDconst 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 2000const 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 -> PROCESSINGsession.submit(now)
// 5) L orchestrateur appelle les providers, puis sur chaque retour :gift.markGiftDebited({ ... }) // PENDING -> CAPTUREDpsp.markPspCaptured(now) // HELD -> CAPTUREDsession.recomputeStatusFromLegs(now) // tous CAPTURED -> derive COMPLETEDsession.markCompleted(now) // PROCESSING -> COMPLETEDL 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.