Contrats inter-briques
C’est ce qui rend Titreo E-Pay extensible sans toucher au cœur. Le core API ne connaît aucun PSP nominalement : il opère sur des contrats abstraits, résolus à l’exécution par des registries.
Source de vérité :
ecosystem/CONTRACTS.md. Cette page la résume et la relie au code réel.
Le pattern « paire client + serveur »
Section intitulée « Le pattern « paire client + serveur » »Chaque brique PSP ou émetteur gift est une paire :
flowchart TB
subgraph nav["Navigateur (non fiable)"]
CC["client adapter<br/>implémente ClientPspAdapter / ClientGiftAdapter"]
end
subgraph srv["Serveur (de confiance)"]
SS["server adapter<br/>implémente PaymentProvider / GiftCardProvider"]
end
CC -->|"capturePayload opaque { kind, data }"| API["core API"]
API -->|"resolve via Registry"| SS
- Le client adapter monte l’UI (Stripe Elements, Adyen Web…), tokenize côté navigateur, et renvoie un payload opaque. Il est chargé dynamiquement via le manifest signé.
- Le server adapter sait décoder ce payload et parle au PSP avec les credentials du marchand. Il est résolu par un registry.
Contrats serveur
Section intitulée « Contrats serveur »PaymentProvider
Section intitulée « PaymentProvider »L’interface que tout adapter PSP serveur implémente. Forme documentée dans CONTRACTS.md :
export type PaymentProvider = { readonly type: string // 'stripe' | 'adyen' | … authorize(params): Promise<AuthorizeResult> // → HELD | REQUIRES_ACTION | FAILED capture(params): Promise<CaptureResult> void(params): Promise<VoidResult> refund(params): Promise<RefundResult> // …vérification de webhook}AuthorizeResult est une union discriminée :
type AuthorizeResult = | { status: 'HELD'; authId; providerRef; raw } | { status: 'REQUIRES_ACTION'; authId; actionUrl; raw } // 3DS / redirection | { status: 'FAILED'; errorCode; errorMessage; raw }GiftCardProvider
Section intitulée « GiftCardProvider »export type GiftCardProvider = { readonly type: string // 'titreo' | 'edenred' | … lookupBalance(input): Promise<{ balance; cardLast4; emitter }> debit(input): Promise<DebitResult> cancel(input): Promise<CancelResult> refund(input): Promise<RefundResult>}Implémentations : TitreoGiftCardAdapter (réel) et MockGiftCardAdapter (dev/tests), dans
api/src/infrastructure/titreo/ et api/src/infrastructure/gift/.
Les registries
Section intitulée « Les registries »Le cœur ne fait jamais new StripeAdapter(). Il demande au registry :
PspRegistry.get(providerType, merchantId): Promise<PaymentProvider>— implémenté parConfigBackedPspRegistry, qui charge la config marchand chiffrée depuis la base puis appelle la factory du PSP.api/src/infrastructure/psp/in-memory-psp-registry.ts.GiftCardRegistry.get(emitterType): GiftCardProvider.
Contrats client (navigateur)
Section intitulée « Contrats client (navigateur) »ClientPspAdapter
Section intitulée « ClientPspAdapter »export type ClientPspAdapter = { readonly type: string mount(container: HTMLElement, options: MountOptions): Promise<void> capture(): Promise<CapturePayload> // { kind, data } opaque handleAction?(actionUrl: string): Promise<{ status: 'COMPLETED' | 'FAILED' }> // 3DS destroy(): void}MountOptions transmet sessionId, amount, currency, la config publique issue du manifest
(ex. clé publishable Stripe), la locale et des themeTokens (variables CSS du thème marchand,
passées aux iframes).
ClientGiftAdapter
Section intitulée « ClientGiftAdapter »export type ClientGiftAdapter = { readonly type: string mount(container, options): Promise<void> lookupBalance(): Promise<{ cardToken; cardLast4; emitter; availableBalance }> destroy(): void}Le numéro de carte ne quitte jamais HTTPS : la brique cliente envoie carte + PIN à l’API Titreo
(POST /v1/gift-cards/lookup), qui forwarde à l’émetteur. Le cœur ne voit qu’un cardToken opaque.
Le registre navigateur
Section intitulée « Le registre navigateur »Le shell checkout-sdk expose un registre global ; chaque module d’adapter s’y auto-enregistre
après chargement :
window.TitreoEpay = { adapters: { psp: Map<string, ClientPspAdapter>, gift: Map<string, ClientGiftAdapter> }, registerPsp(adapter): void, registerGift(adapter): void,}Le flux complet, contrat par contrat
Section intitulée « Le flux complet, contrat par contrat »sequenceDiagram
participant L as Loader (checkout-sdk)
participant API as Core API
participant CA as ClientPspAdapter
participant SA as PaymentProvider (server)
participant PSP as PSP réel
L->>API: GET /v1/sessions/:id/bootstrap (manifest signé)
L->>L: vérifie Ed25519 + SRI, charge le module
CA-->>L: window.TitreoEpay.registerPsp(adapter)
L->>CA: mount(container, options)
CA->>CA: capture() → { kind, data }
L->>API: POST /v1/sessions/:id/legs/psp/charge { providerType, capturePayload }
API->>SA: PspRegistry.get(type, merchantId).authorize(...)
SA->>PSP: appel REST avec credentials marchand
PSP-->>SA: HELD → outbox CAPTURE_PSP
Le cœur n’a aucun code spécifique à un PSP donné. Ajouter PayPlug = écrire une nouvelle paire client+serveur conforme à ces contrats, zéro modification du cœur.
Anti-patterns (interdits)
Section intitulée « Anti-patterns (interdits) »- ❌
if (leg.providerType === 'stripe')dansdomain/ouapplication/— toute logique spécifique va dans l’adapter. - ❌
import 'stripe'hors deinfrastructure/psp/. - ❌ Énumérer les
kinddeCapturePayloaddans le cœur. - ❌ Un plugin CMS qui parlerait directement à la base Titreo (un plugin n’est qu’un consumer
HTTP
/v1/*). - ❌ Instancier un adapter en bypass du registry.
Voir le code
Section intitulée « Voir le code »- Les adapters serveur en détail → Couche Infrastructure
- Les adapters client en détail → Adapters clients
Sources de vérité :
ecosystem/CONTRACTS.md,ecosystem/adapters/ADAPTER_CONTRACT.md,ecosystem/plugins/PLUGIN_CONTRACT.md, etdoc/site/content/providers.mdx.