Adapters clients (Stripe, Adyen, gift)
À quoi sert un adapter client
Section intitulée « À quoi sert un adapter client »Le checkout-sdk est un shell visuel 100 % Titreo : il dessine le formulaire, gère le branding, le split gift + PSP, les états et la copy. Mais il ne sait rien faire d’un PSP en particulier : il ne contient aucune ligne de Stripe.js ni d’Adyen Web. Tout ce qui touche à la carte bancaire (saisie du PAN, du CVV, tokenisation, challenge 3DS) doit rester chez le PSP, jamais transiter par le shell ni par le backend Titreo.
Un adapter client est exactement cette pièce manquante : un petit module ESM, chargé dynamiquement dans le navigateur, qui sait piloter un PSP (ou un émetteur de cartes cadeaux). Il monte l’UI sensible du PSP dans un emplacement (slot) fourni par le shell, récupère un jeton opaque au moment de payer, et le rend au shell qui le transmet au backend. Le shell ne connaît jamais le PSP nominalement : il demande au registry l’adapter dont le type correspond au manifest, et délègue.
Conséquence pratique : ajouter le support d’un nouveau PSP = publier un nouvel adapter, sans toucher au shell ni au backend.
flowchart LR
Shell["checkout-sdk (shell Titreo)"] -->|"slot CB + themeTokens"| Adapter["Adapter client (Stripe / Adyen / gift)"]
Adapter -->|"iframe / champs tokenisés"| PSP["PSP (Stripe Elements / Adyen Web)"]
Adapter -->|"capture() -> { kind, data } opaque"| Shell
Shell -->|"capturePayload"| API["Backend Titreo"]
API -->|"charge serveur"| PSP
Le contrat ClientPspAdapter / ClientGiftAdapter
Section intitulée « Le contrat ClientPspAdapter / ClientGiftAdapter »Le contrat de référence est décrit dans Contrats inter-briques. Chaque adapter réimplémente localement ces types (« mirror ») dans son propre src/types.ts — il n’importe pas le checkout-sdk, pour rester totalement découplé. Le commentaire en tête de chaque fichier le rappelle : Source de vérité: ecosystem/CONTRACTS.md.
Contrat PSP
Section intitulée « Contrat PSP »Côté PSP (adapters/stripe-client/src/types.ts, adapters/adyen-client/src/types.ts) :
export type CapturePayload = { kind: string data: unknown}
export type MountOptions = { sessionId: string amount: number currency: string config: Record<string, unknown> locale?: string themeTokens?: ThemeTokens}
export type ClientPspAdapter = { readonly type: string mount(container: HTMLElement, options: MountOptions): Promise<void> capture(): Promise<CapturePayload> updateAmount?(amount: number, currency: string): Promise<void> handleAction?(actionUrl: string): Promise<{ status: 'COMPLETED' | 'FAILED' }> destroy(): void}| Méthode | Rôle |
|---|---|
type |
Identifiant string ('stripe', 'adyen'…) sous lequel l’adapter s’enregistre dans le registry. Doit matcher le type du manifest. |
mount(container, options) |
Charge le SDK PSP, instancie son UI dans container, applique themeTokens et locale. |
capture() |
Déclenche la tokenisation côté client et retourne un CapturePayload opaque { kind, data }. |
updateAmount? |
Optionnelle. Recalcule le montant côté PSP quand le split gift + PSP change. Présente chez Stripe, absente chez Adyen. |
handleAction? |
Optionnelle. Gère le challenge 3DS à partir d’un actionUrl fourni par le backend. |
destroy() |
Démonte l’UI PSP et nettoie le container. |
Contrat gift
Section intitulée « Contrat gift »Côté gift card (adapters/titreo-gift-client/src/types.ts), le contrat est différent : pas de tokenisation carte mais une vérification de solde contre le backend.
export type GiftMountOptions = { sessionId: string apiBaseUrl: string apiToken: string locale?: string themeTokens?: ThemeTokens config?: Record<string, unknown>}
export type GiftLookupResult = { cardToken: string cardLast4: string emitter: string availableBalance: number currency: string}
export type ClientGiftAdapter = { readonly type: string mount(container: HTMLElement, options: GiftMountOptions): Promise<void> lookupBalance(): Promise<GiftLookupResult> destroy(): void}Différences notables avec le contrat PSP : GiftMountOptions reçoit apiBaseUrl + apiToken (l’adapter gift appelle lui-même le backend), il n’y a pas de capture() ni de handleAction(), et la méthode centrale est lookupBalance() qui retourne un solde et un cardToken.
Le registry navigateur et l’auto-enregistrement
Section intitulée « Le registry navigateur et l’auto-enregistrement »Les adapters ne sont jamais importés statiquement par le shell. Le loader pose d’abord un objet global window.TitreoEpay (le registry, défini dans checkout-sdk/src/loader/registry.ts), puis charge dynamiquement les modules adapters. Chaque adapter s’auto-enregistre à l’import via le code de son index.ts.
Forme réelle du registry (à plat — psp et gift sont des Map, pas adapters.psp) :
window.TitreoEpay = { psp: new Map<string, ClientPspAdapter>(), gift: new Map<string, ClientGiftAdapter>(), registerPsp(adapter) { psp.set(adapter.type, adapter) }, registerGift(adapter) { gift.set(adapter.type, adapter) }, ready: true,}Bloc d’auto-enregistrement, identique chez Stripe et Adyen (adapters/stripe-client/src/index.ts) :
if (typeof window !== 'undefined') { if (!window.TitreoEpay) { queueMicrotask(() => { window.TitreoEpay?.registerPsp(new StripeClientAdapter()) }) } else { window.TitreoEpay.registerPsp(new StripeClientAdapter()) }}Le queueMicrotask couvre le cas où l’adapter est évalué avant que le registry n’existe : l’enregistrement est différé d’un tick. Côté gift (adapters/titreo-gift-client/src/index.ts), la logique est la même mais teste reg?.registerGift avant d’appeler registerGift(new TitreoGiftClientAdapter()).
Le shell, lui, résout l’adapter sans connaître le PSP : window.TitreoEpay.psp.get(manifest.psp_adapter?.type ?? '') (cf. checkout-sdk/src/components/checkout.ts).
sequenceDiagram participant Loader as "Loader / checkout-sdk" participant Reg as "window.TitreoEpay (registry)" participant Mod as "Module adapter (ESM)" Loader->>Reg: ensureRegistry() pose psp/gift Maps Loader->>Mod: import dynamique (CDN, integrity SRI) Mod->>Reg: registerPsp(new StripeClientAdapter()) Loader->>Reg: psp.get(manifest.type) Reg-->>Loader: adapter Loader->>Mod: mount(slot, options)
Cycle de vie runtime
Section intitulée « Cycle de vie runtime »Le même enchaînement s’applique aux deux adapters PSP. Le gift suit une variante centrée sur lookupBalance.
sequenceDiagram
participant Shell as "checkout-sdk"
participant A as "Adapter PSP"
participant PSP as "SDK PSP (Stripe/Adyen)"
participant API as "Backend Titreo"
Shell->>A: mount(slot, { amount, currency, config, themeTokens, locale })
A->>PSP: charge SDK + monte champs CB skinnés
Note over A,PSP: saisie CB par l'utilisateur (iframe sandbox PSP)
Shell->>A: capture()
A->>PSP: tokenise côté client
PSP-->>A: token (payment method / encrypted)
A-->>Shell: { kind, data } opaque
Shell->>API: charge serveur avec capturePayload
API-->>Shell: requires-action (actionUrl 3DS) ?
Shell->>A: handleAction(actionUrl)
A->>PSP: 3DS (redirect ou challenge inline)
Adapter Stripe — stripe-client
Section intitulée « Adapter Stripe — stripe-client »Maturité : actif. Payment Element (cartes), UI Stripe Elements skinnée via l’appearance API. Package @titreo-epay/adapter-stripe-client, peerDependency @stripe/stripe-js (>=4 <5).
| Fichier | Rôle |
|---|---|
adapters/stripe-client/src/index.ts |
Réexports publics + auto-enregistrement window.TitreoEpay.registerPsp(new StripeClientAdapter()). |
adapters/stripe-client/src/adapter.ts |
Classe StripeClientAdapter (le contrat ClientPspAdapter) + setStripeFactory() pour injecter un loadStripe mocké en test. |
adapters/stripe-client/src/theme.ts |
tokensToStripeAppearance() : theme tokens Titreo → appearance Stripe. |
adapters/stripe-client/src/types.ts |
Types mirror du contrat + StripeClientConfig ({ client_key, options?: { locale } }). |
Flux runtime (résumé du adapter.ts) :
mount(): litconfig.client_key(sinonthrow), appelleloadStripe(client_key)via la factory (defaultFactoryimporte@stripe/stripe-js, ou la factory injectée parsetStripeFactory). Créestripe.elements({ mode: 'payment', amount, currency, payment_method_types: ['card'], paymentMethodCreation: 'manual', locale, appearance }), insère unslot(data-titreo-stripe-payment,min-height: 280px), monte un Payment Element en layouttabs. UnreadyPromisese résout sur l’eventreadyde l’élément, avec un timeout de sécurité de 6 s si l’event ne se déclenche jamais.updateAmount(amount, currency):elements.update({ amount, currency })— recalcul du montant côté Stripe quand le split change.capture(): attendreadyPromise, faitelements.submit()(erreur →throw), puisstripe.createPaymentMethod({ elements }). Retourne :
{ kind: 'stripe_payment_method', data: { id: paymentMethod.id } }handleAction(actionUrl): 3DS géré côté backend viaPaymentIntent.next_action.redirect_to_url; l’adapter fait simplementwindow.location.assign(actionUrl)et retourne{ status: 'COMPLETED' }(ouFAILEDsi pas d’URL / hors navigateur).destroy():element.destroy()(try/catch silencieux), vide lecontainer, remet tous les champs internes ànull.
Côté backend, le data.id sert à créer un PaymentIntent en capture manuelle (auth/capture séparés via la state machine Titreo).
Adapter Adyen — adyen-client
Section intitulée « Adapter Adyen — adyen-client »Maturité : actif. Adyen Web Components Card, champs CB skinnés via l’API styles. Package @titreo-epay/adapter-adyen-client, peerDependency @adyen/adyen-web (>=5.55 <7).
| Fichier | Rôle |
|---|---|
adapters/adyen-client/src/index.ts |
Réexports publics + auto-enregistrement registerPsp(new AdyenClientAdapter()). |
adapters/adyen-client/src/adapter.ts |
Classe AdyenClientAdapter + setAdyenCheckoutFactory() pour injecter un AdyenCheckout mocké en test. |
adapters/adyen-client/src/theme.ts |
tokensToAdyenStyles() : theme tokens Titreo → styles Adyen. |
adapters/adyen-client/src/types.ts |
Types mirror du contrat + AdyenClientConfig ({ client_key, environment: 'test' | 'live', options?: { locale } }). |
Flux runtime (résumé du adapter.ts) :
mount(): litconfig.client_key(sinonthrow),environment(défaut'test'),locale(défaut'fr-FR'). Crée l’instanceAdyenCheckout({ clientKey, environment, locale, amount: { value, currency }, analytics: { enabled: false }, onChange }). Le callbackonChangestocke le dernierstate({ isValid, data: { paymentMethod } }) danslatestState. Insère unslot(data-titreo-adyen-card) et monte un Card Component (hasHolderName,holderNameRequired,brands: ['mc','visa','amex','cartebancaire'],styles).capture(): sansupdateAmount(le contrat Adyen mirror ne l’expose pas). Vérifie qu’unlatestStateexiste et qu’il estisValid(sinonthrow). Retourne :
{ kind: 'adyen_payment_method', data: latestState.data.paymentMethod }handleAction(actionUrl):JSON.parse(actionUrl)(fallback{ type: 'redirect', url: actionUrl }si non-JSON), crée un nouveauslot, instanciecheckout.createFromAction(action)et le monte → challenge 3DS2 inline. Retourne{ status: 'COMPLETED' }, ouFAILEDsicreateFromActionou le container manque.destroy():card.unmount()(try/catch silencieux), vide lecontainer, remet ànull.
Côté backend, AdyenAdapter (serveur) parse le paymentMethod et fait POST /payments avec manualCapture: true.
Adapter gift Titreo — titreo-gift-client
Section intitulée « Adapter gift Titreo — titreo-gift-client »Maturité : prototype. Pas de theme.ts, pas de README, pas de répertoire tests/ — contrairement aux deux PSP. Package @titreo-epay/adapter-titreo-gift-client, sans peerDependency (UI DOM native, aucun SDK tiers).
| Fichier | Rôle |
|---|---|
adapters/titreo-gift-client/src/index.ts |
Réexports + auto-enregistrement window.TitreoEpay.registerGift(new TitreoGiftClientAdapter()). |
adapters/titreo-gift-client/src/adapter.ts |
Classe TitreoGiftClientAdapter (le contrat ClientGiftAdapter), type = 'titreo'. |
adapters/titreo-gift-client/src/types.ts |
Types mirror du contrat gift (GiftMountOptions, GiftLookupResult, ClientGiftAdapter). |
Flux runtime (résumé du adapter.ts) :
mount(): construit en DOM natif un petit formulaire (label +<input>numériquemaxLength=32+ zone de statut). Pas de SDK tiers, pas d’iframe. Quelques theme tokens sont appliqués inline (voir mapping ci-dessous). Le label et le placeholder sont localisés selonlocale('en'vs autre).lookupBalance(): nettoie le numéro saisi, valide^\d{8,32}$(sinon affiche « Numéro carte invalide » etthrow 'INVALID_CARD_NUMBER'). DétermineproviderTypedepuisconfig.providerType(défaut'titreo'), puis appelle le backend :
POST {apiBaseUrl}/v1/sessions/{sessionId}/gift-cards/check-balanceAuthorization: Bearer {apiToken}body: { providerType, cardNumber }Sur réponse status: 'OK', retourne { cardToken, cardLast4, emitter, availableBalance, currency }. Sur status: 'FAILED' ou HTTP non-2xx, affiche le message d’erreur et throw.
3. destroy() : vide le container et remet les champs internes à null.
Mapping des theme tokens
Section intitulée « Mapping des theme tokens »Les theme tokens sont des variables CSS Titreo (Record<string, string>) transmises par le shell dans MountOptions.themeTokens. Chaque adapter les traduit vers l’API de styling de son PSP, ce qui donne un rendu visuel cohérent quel que soit le PSP actif.
Stripe — tokensToStripeAppearance()
Section intitulée « Stripe — tokensToStripeAppearance() »Retourne { theme: 'stripe', variables } pour l’appearance API. Chaque token a une valeur de repli si absent.
| Token Titreo | Variable Stripe | Défaut |
|---|---|---|
colorPrimary |
colorPrimary |
#0b1020 |
colorBackground |
colorBackground |
#ffffff |
colorText |
colorText |
#1a1a1a |
colorDanger |
colorDanger |
#dc2626 |
fontFamily |
fontFamily |
system-ui, sans-serif |
borderRadius |
borderRadius |
8px |
spacingUnit |
spacingUnit |
4px |
Adyen — tokensToAdyenStyles()
Section intitulée « Adyen — tokensToAdyenStyles() »Retourne un objet styles (base / error / placeholder / validated) injecté dans l’iframe des champs CB.
| Token Titreo | Cible Adyen | Défaut |
|---|---|---|
colorText |
base.color, validated.color |
#1a1a1a |
colorTextSecondary |
placeholder.color, base.::placeholder.color |
#9ca3af |
colorDanger |
error.color |
#dc2626 |
fontFamily |
base.fontFamily |
system-ui, sans-serif |
base.fontSize (15px) et base.fontSmoothing (antialiased) sont fixés en dur dans l’adapter.
Gift — appliqués inline (prototype)
Section intitulée « Gift — appliqués inline (prototype) »Pas de fonction de mapping dédiée : mount() lit quelques tokens directement et les pose en CSS inline sur les éléments.
| Token Titreo | Usage | Défaut |
|---|---|---|
colorPrimary |
couleur de bordure de l’<input> au focus |
#0b1020 |
colorBorder |
bordure de l’<input> au repos |
#e5e7eb |
borderRadius |
arrondi de l’<input> |
8px |
Build et distribution
Section intitulée « Build et distribution »Les trois adapters partagent la même configuration (package.json, tsup.config.ts) :
| Aspect | Valeur |
|---|---|
| Scripts npm | build (tsup), typecheck (tsc --noEmit), lint / lint:fix (Biome), test / test:watch (Vitest) |
| Sorties tsup | dist/index.js (ESM, PSP en external) et dist/index.cdn.js (bundle navigateur, SDK PSP inlined) |
| Module type | ESM ("type": "module"), target: es2022 |
| Gestionnaire | pnpm 10.16.0, Node >=20 |
Pour aller plus loin
Section intitulée « Pour aller plus loin »- Contrats inter-briques — source de vérité des types
ClientPspAdapter/ClientGiftAdapter - checkout-sdk — le shell qui charge et pilote ces adapters
- Sécurité — chargement par manifest signé, integrity SRI, tokens session-scoped
- Backend — côté serveur des captures (
stripe_payment_method,adyen_payment_method) et du check-balance gift - Environnement de dev local — CDN adapters et compose de dev
- Vue d’ensemble de l’écosystème — place des adapters dans la chaîne checkout