Aller au contenu

Adapters clients (Stripe, Adyen, gift)

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 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.

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.

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.

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)

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)

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) :

  1. mount() : lit config.client_key (sinon throw), appelle loadStripe(client_key) via la factory (defaultFactory importe @stripe/stripe-js, ou la factory injectée par setStripeFactory). Crée stripe.elements({ mode: 'payment', amount, currency, payment_method_types: ['card'], paymentMethodCreation: 'manual', locale, appearance }), insère un slot (data-titreo-stripe-payment, min-height: 280px), monte un Payment Element en layout tabs. Un readyPromise se résout sur l’event ready de l’élément, avec un timeout de sécurité de 6 s si l’event ne se déclenche jamais.
  2. updateAmount(amount, currency) : elements.update({ amount, currency }) — recalcul du montant côté Stripe quand le split change.
  3. capture() : attend readyPromise, fait elements.submit() (erreur → throw), puis stripe.createPaymentMethod({ elements }). Retourne :
{ kind: 'stripe_payment_method', data: { id: paymentMethod.id } }
  1. handleAction(actionUrl) : 3DS géré côté backend via PaymentIntent.next_action.redirect_to_url ; l’adapter fait simplement window.location.assign(actionUrl) et retourne { status: 'COMPLETED' } (ou FAILED si pas d’URL / hors navigateur).
  2. destroy() : element.destroy() (try/catch silencieux), vide le container, 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).

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) :

  1. mount() : lit config.client_key (sinon throw), environment (défaut 'test'), locale (défaut 'fr-FR'). Crée l’instance AdyenCheckout({ clientKey, environment, locale, amount: { value, currency }, analytics: { enabled: false }, onChange }). Le callback onChange stocke le dernier state ({ isValid, data: { paymentMethod } }) dans latestState. Insère un slot (data-titreo-adyen-card) et monte un Card Component (hasHolderName, holderNameRequired, brands: ['mc','visa','amex','cartebancaire'], styles).
  2. capture() : sans updateAmount (le contrat Adyen mirror ne l’expose pas). Vérifie qu’un latestState existe et qu’il est isValid (sinon throw). Retourne :
{ kind: 'adyen_payment_method', data: latestState.data.paymentMethod }
  1. handleAction(actionUrl) : JSON.parse(actionUrl) (fallback { type: 'redirect', url: actionUrl } si non-JSON), crée un nouveau slot, instancie checkout.createFromAction(action) et le monte → challenge 3DS2 inline. Retourne { status: 'COMPLETED' }, ou FAILED si createFromAction ou le container manque.
  2. destroy() : card.unmount() (try/catch silencieux), vide le container, remet à null.

Côté backend, AdyenAdapter (serveur) parse le paymentMethod et fait POST /payments avec manualCapture: true.

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) :

  1. mount() : construit en DOM natif un petit formulaire (label + <input> numérique maxLength=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 selon locale ('en' vs autre).
  2. lookupBalance() : nettoie le numéro saisi, valide ^\d{8,32}$ (sinon affiche « Numéro carte invalide » et throw 'INVALID_CARD_NUMBER'). Détermine providerType depuis config.providerType (défaut 'titreo'), puis appelle le backend :
POST {apiBaseUrl}/v1/sessions/{sessionId}/gift-cards/check-balance
Authorization: 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.

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.

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

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.

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

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